Forráskód Böngészése

add XOAUTH2

start the countdown, let's see how long it takes for your favorite
Go-based proprietary SFTP server to notice this change, copy the SFTPGo
code and thus violate its license, and announce the same feature :)

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 2 éve
szülő
commit
48939b2b4f

+ 7 - 1
docs/full-configuration.md

@@ -453,11 +453,17 @@ The configuration file contains the following sections:
   - `from`, string. From address, for example `SFTPGo <[email protected]>`. Many SMTP servers reject emails without a `From` header so, if not set, SFTPGo will try to use the username as fallback, this may or may not be appropriate. Default: blank
   - `user`, string. SMTP username. Default: blank
   - `password`, string. SMTP password. Leaving both username and password empty the SMTP authentication will be disabled. Default: blank
-  - `auth_type`, integer. 0 means `Plain`, 1 means `Login`, 2 means `CRAM-MD5`. Default: `0`.
+  - `auth_type`, integer. 0 means `Plain`, 1 means `Login`, 2 means `CRAM-MD5`, 3 means `XOAUTH2`. Default: `0`.
   - `encryption`, integer. 0 means no encryption, 1 means `TLS`, 2 means `STARTTLS`. Default: `0`.
   - `domain`, string. Domain to use for `HELO` command, if empty `localhost` will be used. Default: blank.
   - `templates_path`, string. Path to the email templates. This can be an absolute path or a path relative to the config dir. Templates are searched within a subdirectory named "email" in the specified path. You can customize the email templates by simply specifying an alternate path and putting your custom templates there.
   - `debug`, integer. Set to `1` to enable SMTP debug. Default: `0`.
+  - `oauth2`, struct containing OAuth2 related configurations:
+    - `provider`, integer, 0 means `Google`, 1 means `Microsoft`. Default: `0`.
+    - `tenant`, string. Azure Active Directory tenant for the Microsoft provider. Typical values are `common`, `organizations`, `consumers` or tenant identifier. If empty `common` is used. Default: blank.
+    - `client_id`, string. Default: blank.
+    - `client_secret`, string. Default: blank.
+    - `refresh_token`, string. Default: blank.
 
 </details>
 <details><summary><font size=4>Plugins</font></summary>

+ 18 - 18
go.mod

@@ -19,7 +19,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.19.8
 	github.com/aws/aws-sdk-go-v2/service/sts v1.19.0
 	github.com/bmatcuk/doublestar/v4 v4.6.0
-	github.com/cockroachdb/cockroach-go/v2 v2.3.3
+	github.com/cockroachdb/cockroach-go/v2 v2.3.4
 	github.com/coreos/go-oidc/v3 v3.6.0
 	github.com/drakkan/webdav v0.0.0-20230227175313-32996838bcd8
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
@@ -34,14 +34,14 @@ require (
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/google/uuid v1.3.0
 	github.com/hashicorp/go-hclog v1.5.0
-	github.com/hashicorp/go-plugin v1.4.10-0.20230403150917-e889c1ba1044
+	github.com/hashicorp/go-plugin v1.4.10
 	github.com/hashicorp/go-retryablehttp v0.7.2
-	github.com/jackc/pgx/v5 v5.3.2-0.20230520135323-70a200cff4d4
+	github.com/jackc/pgx/v5 v5.3.2-0.20230603125928-d9560c78b8e6
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/klauspost/compress v1.16.5
 	github.com/lestrrat-go/jwx/v2 v2.0.9
 	github.com/lithammer/shortuuid/v3 v3.0.7
-	github.com/mattn/go-sqlite3 v1.14.16
+	github.com/mattn/go-sqlite3 v1.14.17
 	github.com/mhale/smtpd v0.8.0
 	github.com/minio/sio v0.3.1
 	github.com/otiai10/copy v1.11.0
@@ -54,16 +54,16 @@ require (
 	github.com/rs/xid v1.5.0
 	github.com/rs/zerolog v1.29.1
 	github.com/sftpgo/sdk v0.1.5-0.20230524172149-afb96ebee860
-	github.com/shirou/gopsutil/v3 v3.23.4
+	github.com/shirou/gopsutil/v3 v3.23.5
 	github.com/spf13/afero v1.9.5
 	github.com/spf13/cobra v1.7.0
-	github.com/spf13/viper v1.15.0
-	github.com/stretchr/testify v1.8.3
+	github.com/spf13/viper v1.16.0
+	github.com/stretchr/testify v1.8.4
 	github.com/studio-b12/gowebdav v0.0.0-20230203202212-3282f94193f2
 	github.com/subosito/gotenv v1.4.2
 	github.com/unrolled/secure v1.13.0
 	github.com/wagslane/go-password-validator v0.3.0
-	github.com/wneessen/go-mail v0.3.9
+	github.com/wneessen/go-mail v0.3.10-0.20230531074101-4100fef083df
 	github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a
 	go.etcd.io/bbolt v1.3.7
 	go.uber.org/automaxprocs v1.5.2
@@ -74,15 +74,15 @@ require (
 	golang.org/x/sys v0.8.0
 	golang.org/x/term v0.8.0
 	golang.org/x/time v0.3.0
-	google.golang.org/api v0.124.0
+	google.golang.org/api v0.125.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 )
 
 require (
 	cloud.google.com/go v0.110.2 // indirect
-	cloud.google.com/go/compute v1.19.3 // indirect
+	cloud.google.com/go/compute v1.20.0 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
-	cloud.google.com/go/iam v1.0.1 // indirect
+	cloud.google.com/go/iam v1.1.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect
 	github.com/ajg/form v1.5.1 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
@@ -115,7 +115,7 @@ require (
 	github.com/google/go-cmp v0.5.9 // indirect
 	github.com/google/s2a-go v0.1.4 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
-	github.com/googleapis/gax-go/v2 v2.9.1 // indirect
+	github.com/googleapis/gax-go/v2 v2.10.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/hashicorp/yamux v0.1.1 // indirect
@@ -123,7 +123,7 @@ require (
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
-	github.com/klauspost/cpuid/v2 v2.2.4 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.5 // indirect
 	github.com/kr/fs v0.1.0 // indirect
 	github.com/lestrrat-go/blackmagic v1.0.1 // indirect
 	github.com/lestrrat-go/httpcc v1.0.1 // indirect
@@ -152,17 +152,17 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/tklauser/go-sysconf v0.3.11 // indirect
-	github.com/tklauser/numcpus v0.6.0 // indirect
+	github.com/tklauser/numcpus v0.6.1 // indirect
 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	golang.org/x/mod v0.10.0 // indirect
 	golang.org/x/text v0.9.0 // indirect
-	golang.org/x/tools v0.9.1 // indirect
+	golang.org/x/tools v0.9.3 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e // indirect
+	google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect
 	google.golang.org/grpc v1.55.0 // indirect
 	google.golang.org/protobuf v1.30.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect

+ 37 - 37
go.sum

@@ -124,8 +124,8 @@ cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARy
 cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
 cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA=
 cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
-cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
-cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
+cloud.google.com/go/compute v1.20.0 h1:cUOcywWuowO9It2i1KX1lIb0HH7gLv6nENKuZGnlcSo=
+cloud.google.com/go/compute v1.20.0/go.mod h1:kn5BhC++qUWR/AM3Dn21myV7QbgqejW04cAOrtppaQI=
 cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU=
 cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
 cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM=
@@ -218,8 +218,8 @@ cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHD
 cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg=
 cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE=
 cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
-cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
-cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
+cloud.google.com/go/iam v1.1.0 h1:67gSqaPukx7O8WLLHMa0PNs3EBGd2eE4d+psbO/CO94=
+cloud.google.com/go/iam v1.1.0/go.mod h1:nxdHjaKfCr7fNYx/HJMM8LgiMugmveWlkatear5gVyk=
 cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc=
 cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A=
 cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM=
@@ -697,8 +697,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
 github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
-github.com/cockroachdb/cockroach-go/v2 v2.3.3 h1:fNmtG6XhoA1DhdDCIu66YyGSsNb1szj4CaAsbDxRmy4=
-github.com/cockroachdb/cockroach-go/v2 v2.3.3/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8=
+github.com/cockroachdb/cockroach-go/v2 v2.3.4 h1:dm6K7p7VOldWbgUllY4D/1Qtqv/D0UKm6OLhpF53aJU=
+github.com/cockroachdb/cockroach-go/v2 v2.3.4/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8=
 github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
 github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo=
 github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA=
@@ -1227,8 +1227,8 @@ github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK
 github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
 github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
 github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
-github.com/googleapis/gax-go/v2 v2.9.1 h1:DpTpJqzZ3NvX9zqjhIuI1oVzYZMvboZe+3LoeEIJjHM=
-github.com/googleapis/gax-go/v2 v2.9.1/go.mod h1:4FG3gMrVZlyMp5itSYKMU9z/lBE7+SbnUOvzH2HqbEY=
+github.com/googleapis/gax-go/v2 v2.10.0 h1:ebSgKfMxynOdxw8QQuFOKMgomqeLGPqNLQox2bo42zg=
+github.com/googleapis/gax-go/v2 v2.10.0/go.mod h1:4UOEnMCrxsSqQ940WnTiD6qJ63le2ev3xfyagutxiPw=
 github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
 github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU=
 github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
@@ -1294,8 +1294,8 @@ github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
 github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/hashicorp/go-plugin v1.4.10-0.20230403150917-e889c1ba1044 h1:dEFpX4X++vjyeh0mqp0rGbTF2/gXfSc8bOKSTrh0ucg=
-github.com/hashicorp/go-plugin v1.4.10-0.20230403150917-e889c1ba1044/go.mod h1:6/1TEzT0eQznvI/gV2CM29DLSkAK/e58mUWKVsPaph0=
+github.com/hashicorp/go-plugin v1.4.10 h1:xUbmA4jC6Dq163/fWcp8P3JuHilrHHMLNRxzGQJ9hNk=
+github.com/hashicorp/go-plugin v1.4.10/go.mod h1:6/1TEzT0eQznvI/gV2CM29DLSkAK/e58mUWKVsPaph0=
 github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
 github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
 github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0=
@@ -1393,8 +1393,8 @@ github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9
 github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
 github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
 github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
-github.com/jackc/pgx/v5 v5.3.2-0.20230520135323-70a200cff4d4 h1:ZNj2ThNr8qk34TWl+5uBDC01fa+bPynz2a8ju5nhvwU=
-github.com/jackc/pgx/v5 v5.3.2-0.20230520135323-70a200cff4d4/go.mod h1:sU+RaYl9qnhD3Ce+mwnFii6YEPx70mCYghBzKvqq4qo=
+github.com/jackc/pgx/v5 v5.3.2-0.20230603125928-d9560c78b8e6 h1:XSDMgUsVBRwSSqRvsIOh78HavVE1WNgkIhZXLhtkKxs=
+github.com/jackc/pgx/v5 v5.3.2-0.20230603125928-d9560c78b8e6/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY=
 github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
@@ -1446,8 +1446,9 @@ github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
 github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
 github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
 github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
+github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
 github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
 github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -1540,8 +1541,8 @@ github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp
 github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
-github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
-github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
+github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
 github.com/matttproud/golang_protobuf_extensions v1.0.2/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
@@ -1844,14 +1845,13 @@ github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/sftpgo/sdk v0.1.5-0.20230524172149-afb96ebee860 h1:adaUl1JO/4bPhQuhSH7bQJ2o+2CW6Ry7R2w2SltS/PE=
 github.com/sftpgo/sdk v0.1.5-0.20230524172149-afb96ebee860/go.mod h1:TjeoMWS0JEXt9RukJveTnaiHj4+MVLtUiDC+mY++Odk=
-github.com/shirou/gopsutil/v3 v3.23.4 h1:hZwmDxZs7Ewt75DV81r4pFMqbq+di2cbt9FsQBqLD2o=
-github.com/shirou/gopsutil/v3 v3.23.4/go.mod h1:ZcGxyfzAMRevhUR2+cfhXDH6gQdFYE/t8j1nsU4mPI8=
-github.com/shoenig/go-m1cpu v0.1.5/go.mod h1:Wwvst4LR89UxjeFtLRMrpgRiyY4xPsejnVZym39dbAQ=
+github.com/shirou/gopsutil/v3 v3.23.5 h1:5SgDCeQ0KW0S4N0znjeM/eFHXXOKyv2dVNgRq/c9P6Y=
+github.com/shirou/gopsutil/v3 v3.23.5/go.mod h1:Ng3Maa27Q2KARVJ0SPZF5NdrQSC3XHKP8IIWrHgMeLY=
 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
 github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
 github.com/shoenig/test v0.6.0/go.mod h1:xYtyGBC5Q3kzCNyJg/SjgNpfAa2kvmgA0i5+lQso8x0=
-github.com/shoenig/test v0.6.3/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
 github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
+github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
@@ -1904,8 +1904,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
 github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw=
-github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
-github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
+github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
+github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
 github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8=
 github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
@@ -1930,8 +1930,9 @@ github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
 github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/studio-b12/gowebdav v0.0.0-20230203202212-3282f94193f2 h1:VsBj3UD2xyAOu7kJw6O/2jjG2UXLFoBzihqDU9Ofg9M=
 github.com/studio-b12/gowebdav v0.0.0-20230203202212-3282f94193f2/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
@@ -1946,8 +1947,9 @@ github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955u
 github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
 github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM=
 github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI=
-github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms=
 github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4=
+github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@@ -1975,8 +1977,8 @@ github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSV
 github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
 github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
 github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
-github.com/wneessen/go-mail v0.3.9 h1:Q4DbCk3htT5DtDWKeMgNXCiHc4bBY/vv/XQPT6XDXzc=
-github.com/wneessen/go-mail v0.3.9/go.mod h1:zxOlafWCP/r6FEhAaRgH4IC1vg2YXxO0Nar9u0IScZ8=
+github.com/wneessen/go-mail v0.3.10-0.20230531074101-4100fef083df h1:/fA+c44SSsuvyY7m8N/Nm+AQ5ro2docGG7jz9qK9KxY=
+github.com/wneessen/go-mail v0.3.10-0.20230531074101-4100fef083df/go.mod h1:zxOlafWCP/r6FEhAaRgH4IC1vg2YXxO0Nar9u0IScZ8=
 github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
 github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
 github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
@@ -1998,7 +2000,6 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
 github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
@@ -2459,7 +2460,6 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -2592,8 +2592,8 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
 golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
-golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
+golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
+golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2670,8 +2670,8 @@ google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/
 google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
 google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
 google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
-google.golang.org/api v0.124.0 h1:dP6Ef1VgOGqQ8eiv4GiY8RhmeyqzovcXBYPDUYG8Syo=
-google.golang.org/api v0.124.0/go.mod h1:xu2HQurE5gi/3t1aFCvhPD781p0a3p11sdunTJ2BlP4=
+google.golang.org/api v0.125.0 h1:7xGvEY4fyWbhWMHf3R2/4w7L4fXyfpRGE9g6lp8+DCk=
+google.golang.org/api v0.125.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -2816,12 +2816,12 @@ google.golang.org/genproto v0.0.0-20230113154510-dbe35b8444a5/go.mod h1:RGgjbofJ
 google.golang.org/genproto v0.0.0-20230124163310-31e0e69b6fc2/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
 google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
 google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
-google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e h1:Ao9GzfUMPH3zjVfzXG5rlWlk+Q8MXWKwWpwVQE1MXfw=
-google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:zqTuNwFlFRsw5zIts5VnzLQxSRqh+CGOTVMlYbY0Eyk=
-google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e h1:AZX1ra8YbFMSb7+1pI8S9v4rrgRR7jU1FmuFSSjTVcQ=
-google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e h1:NumxXLPfHSndr3wBBdeKiVHjGVFzi9RX2HwwQke94iY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
+google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc h1:8DyZCyvI8mE1IdLy/60bS+52xfymkE72wv1asokgtao=
+google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
+google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc h1:kVKPf/IiYSBWEWtkIn6wZXwWGCnLKcC8oWfZvXjsGnM=
+google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc h1:XSJ8Vk1SWuNr8S18z1NZSziL0CPIXLCCMDOEFtHBOFc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

+ 127 - 31
internal/dataprovider/configs.go

@@ -142,6 +142,70 @@ func (c *SFTPDConfigs) getACopy() *SFTPDConfigs {
 	}
 }
 
+func validateSMTPSecret(secret *kms.Secret, name string) error {
+	if secret.IsRedacted() {
+		return util.NewValidationError(fmt.Sprintf("cannot save a redacted smtp %s", name))
+	}
+	if secret.IsEncrypted() && !secret.IsValid() {
+		return util.NewValidationError(fmt.Sprintf("invalid encrypted smtp %s", name))
+	}
+	if !secret.IsEmpty() && !secret.IsValidInput() {
+		return util.NewValidationError(fmt.Sprintf("invalid smtp %s", name))
+	}
+	if secret.IsPlain() {
+		secret.SetAdditionalData("smtp")
+		if err := secret.Encrypt(); err != nil {
+			return util.NewValidationError(fmt.Sprintf("could not encrypt smtp %s: %v", name, err))
+		}
+	}
+	return nil
+}
+
+// SMTPOAuth2 defines the SMTP related OAuth2 configurations
+type SMTPOAuth2 struct {
+	Provider     int         `json:"provider,omitempty"`
+	Tenant       string      `json:"tenant,omitempty"`
+	ClientID     string      `json:"client_id,omitempty"`
+	ClientSecret *kms.Secret `json:"client_secret,omitempty"`
+	RefreshToken *kms.Secret `json:"refresh_token,omitempty"`
+}
+
+func (c *SMTPOAuth2) validate() error {
+	if c.Provider < 0 || c.Provider > 1 {
+		return util.NewValidationError("smtp oauth2: unsupported provider")
+	}
+	if c.ClientID == "" {
+		return util.NewValidationError("smtp oauth2: client id is required")
+	}
+	if c.ClientSecret == nil {
+		return util.NewValidationError("smtp oauth2: client secret is required")
+	}
+	if c.RefreshToken == nil {
+		return util.NewValidationError("smtp oauth2: refresh token is required")
+	}
+	if err := validateSMTPSecret(c.ClientSecret, "oauth2 client secret"); err != nil {
+		return err
+	}
+	return validateSMTPSecret(c.RefreshToken, "oauth2 refresh token")
+}
+
+func (c *SMTPOAuth2) getACopy() SMTPOAuth2 {
+	var clientSecret, refreshToken *kms.Secret
+	if c.ClientSecret != nil {
+		clientSecret = c.ClientSecret.Clone()
+	}
+	if c.RefreshToken != nil {
+		refreshToken = c.RefreshToken.Clone()
+	}
+	return SMTPOAuth2{
+		Provider:     c.Provider,
+		Tenant:       c.Tenant,
+		ClientID:     c.ClientID,
+		ClientSecret: clientSecret,
+		RefreshToken: refreshToken,
+	}
+}
+
 // SMTPConfigs defines configuration for SMTP
 type SMTPConfigs struct {
 	Host       string      `json:"host,omitempty"`
@@ -153,52 +217,63 @@ type SMTPConfigs struct {
 	Encryption int         `json:"encryption,omitempty"`
 	Domain     string      `json:"domain,omitempty"`
 	Debug      int         `json:"debug,omitempty"`
+	OAuth2     SMTPOAuth2  `json:"oauth2"`
 }
 
-func (c *SMTPConfigs) isEmpty() bool {
+// IsEmpty returns true if the configuration is empty
+func (c *SMTPConfigs) IsEmpty() bool {
 	return c.Host == ""
 }
 
-func (c *SMTPConfigs) validatePassword() error {
-	if c.Password != nil {
-		if c.Password.IsRedacted() {
-			return util.NewValidationError("cannot save a redacted smtp password")
-		}
-		if c.Password.IsEncrypted() && !c.Password.IsValid() {
-			return util.NewValidationError("invalid encrypted smtp password")
-		}
-		if !c.Password.IsEmpty() && !c.Password.IsValidInput() {
-			return util.NewValidationError("invalid smtp password")
-		}
-		if c.Password.IsPlain() {
-			c.Password.SetAdditionalData("smtp")
-			if err := c.Password.Encrypt(); err != nil {
-				return util.NewValidationError(fmt.Sprintf("could not encrypt smtp password: %v", err))
-			}
-		}
-	}
-	return nil
-}
-
 func (c *SMTPConfigs) validate() error {
-	if c.isEmpty() {
+	if c.IsEmpty() {
 		return nil
 	}
 	if c.Port <= 0 || c.Port > 65535 {
 		return util.NewValidationError(fmt.Sprintf("smtp: invalid port %d", c.Port))
 	}
-	if err := c.validatePassword(); err != nil {
-		return err
+	if c.Password != nil && c.AuthType != 3 {
+		if err := validateSMTPSecret(c.Password, "password"); err != nil {
+			return err
+		}
 	}
 	if c.User == "" && c.From == "" {
 		return util.NewValidationError("smtp: from address and user cannot both be empty")
 	}
-	if c.AuthType < 0 || c.AuthType > 2 {
+	if c.AuthType < 0 || c.AuthType > 3 {
 		return util.NewValidationError(fmt.Sprintf("smtp: invalid auth type %d", c.AuthType))
 	}
 	if c.Encryption < 0 || c.Encryption > 2 {
 		return util.NewValidationError(fmt.Sprintf("smtp: invalid encryption %d", c.Encryption))
 	}
+	if c.AuthType == 3 {
+		c.Password = kms.NewEmptySecret()
+		return c.OAuth2.validate()
+	}
+	c.OAuth2 = SMTPOAuth2{}
+	return nil
+}
+
+// TryDecrypt tries to decrypt the encrypted secrets
+func (c *SMTPConfigs) TryDecrypt() error {
+	if c.Password == nil {
+		c.Password = kms.NewEmptySecret()
+	}
+	if c.OAuth2.ClientSecret == nil {
+		c.OAuth2.ClientSecret = kms.NewEmptySecret()
+	}
+	if c.OAuth2.RefreshToken == nil {
+		c.OAuth2.RefreshToken = kms.NewEmptySecret()
+	}
+	if err := c.Password.TryDecrypt(); err != nil {
+		return fmt.Errorf("unable to decrypt smtp password: %w", err)
+	}
+	if err := c.OAuth2.ClientSecret.TryDecrypt(); err != nil {
+		return fmt.Errorf("unable to decrypt smtp oauth2 client secret: %w", err)
+	}
+	if err := c.OAuth2.RefreshToken.TryDecrypt(); err != nil {
+		return fmt.Errorf("unable to decrypt smtp oauth2 refresh token: %w", err)
+	}
 	return nil
 }
 
@@ -217,6 +292,7 @@ func (c *SMTPConfigs) getACopy() *SMTPConfigs {
 		Encryption: c.Encryption,
 		Domain:     c.Domain,
 		Debug:      c.Debug,
+		OAuth2:     c.OAuth2.getACopy(),
 	}
 }
 
@@ -315,16 +391,30 @@ func (c *Configs) PrepareForRendering() {
 	if c.SFTPD != nil && c.SFTPD.isEmpty() {
 		c.SFTPD = nil
 	}
-	if c.SMTP != nil && c.SMTP.isEmpty() {
+	if c.SMTP != nil && c.SMTP.IsEmpty() {
 		c.SMTP = nil
 	}
 	if c.ACME != nil && c.ACME.isEmpty() {
 		c.ACME = nil
 	}
-	if c.SMTP != nil && c.SMTP.Password != nil {
-		c.SMTP.Password.Hide()
-		if c.SMTP.Password.IsEmpty() {
-			c.SMTP.Password = nil
+	if c.SMTP != nil {
+		if c.SMTP.Password != nil {
+			c.SMTP.Password.Hide()
+			if c.SMTP.Password.IsEmpty() {
+				c.SMTP.Password = nil
+			}
+		}
+		if c.SMTP.OAuth2.ClientSecret != nil {
+			c.SMTP.OAuth2.ClientSecret.Hide()
+			if c.SMTP.OAuth2.ClientSecret.IsEmpty() {
+				c.SMTP.OAuth2.ClientSecret = nil
+			}
+		}
+		if c.SMTP.OAuth2.RefreshToken != nil {
+			c.SMTP.OAuth2.RefreshToken.Hide()
+			if c.SMTP.OAuth2.RefreshToken.IsEmpty() {
+				c.SMTP.OAuth2.RefreshToken = nil
+			}
 		}
 	}
 }
@@ -340,6 +430,12 @@ func (c *Configs) SetNilsToEmpty() {
 	if c.SMTP.Password == nil {
 		c.SMTP.Password = kms.NewEmptySecret()
 	}
+	if c.SMTP.OAuth2.ClientSecret == nil {
+		c.SMTP.OAuth2.ClientSecret = kms.NewEmptySecret()
+	}
+	if c.SMTP.OAuth2.RefreshToken == nil {
+		c.SMTP.OAuth2.RefreshToken = kms.NewEmptySecret()
+	}
 	if c.ACME == nil {
 		c.ACME = &ACMEConfigs{}
 	}

+ 2 - 1
internal/dataprovider/session.go

@@ -27,6 +27,7 @@ const (
 	SessionTypeOIDCAuth SessionType = iota + 1
 	SessionTypeOIDCToken
 	SessionTypeResetCode
+	SessionTypeOAuth2Auth
 )
 
 // Session defines a shared session persisted in the data provider
@@ -41,7 +42,7 @@ func (s *Session) validate() error {
 	if s.Key == "" {
 		return errors.New("unable to save a session with an empty key")
 	}
-	if s.Type < SessionTypeOIDCAuth || s.Type > SessionTypeResetCode {
+	if s.Type < SessionTypeOIDCAuth || s.Type > SessionTypeOAuth2Auth {
 		return fmt.Errorf("invalid session type: %v", s.Type)
 	}
 	return nil

+ 69 - 3
internal/httpd/api_configs.go

@@ -18,9 +18,13 @@ import (
 	"net/http"
 
 	"github.com/go-chi/render"
+	"github.com/rs/xid"
+	"golang.org/x/oauth2"
 
 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+	"github.com/drakkan/sftpgo/v2/internal/kms"
 	"github.com/drakkan/sftpgo/v2/internal/smtp"
+	"github.com/drakkan/sftpgo/v2/internal/util"
 )
 
 type smtpTestRequest struct {
@@ -28,6 +32,10 @@ type smtpTestRequest struct {
 	Recipient string `json:"recipient"`
 }
 
+func (r *smtpTestRequest) hasRedactedSecret() bool {
+	return r.Password == redactedSecret || r.OAuth2.ClientSecret == redactedSecret || r.OAuth2.RefreshToken == redactedSecret
+}
+
 func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 
@@ -37,15 +45,29 @@ func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 	}
-	if req.Password == redactedSecret {
+	if req.hasRedactedSecret() {
 		configs, err := dataprovider.GetConfigs()
 		if err != nil {
 			sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
 			return
 		}
 		configs.SetNilsToEmpty()
-		if err := configs.SMTP.Password.TryDecrypt(); err == nil {
-			req.Password = configs.SMTP.Password.GetPayload()
+		if err := configs.SMTP.TryDecrypt(); err == nil {
+			if req.Password == redactedSecret {
+				req.Password = configs.SMTP.Password.GetPayload()
+			}
+			if req.OAuth2.ClientSecret == redactedSecret {
+				req.OAuth2.ClientSecret = configs.SMTP.OAuth2.ClientSecret.GetPayload()
+			}
+			if req.OAuth2.RefreshToken == redactedSecret {
+				req.OAuth2.RefreshToken = configs.SMTP.OAuth2.RefreshToken.GetPayload()
+			}
+		}
+	}
+	if req.AuthType == 3 {
+		if err := req.Config.OAuth2.Validate(); err != nil {
+			sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+			return
 		}
 	}
 	if err := req.SendEmail([]string{req.Recipient}, nil, "SFTPGo - Testing Email Settings",
@@ -55,3 +77,47 @@ func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
 	}
 	sendAPIResponse(w, r, nil, "SMTP connection OK", http.StatusOK)
 }
+
+type oauth2TokenRequest struct {
+	smtp.OAuth2Config
+	BaseRedirectURL string `json:"base_redirect_url"`
+}
+
+func handleSMTPOAuth2TokenRequestPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	var req oauth2TokenRequest
+	err := render.DecodeJSON(r.Body, &req)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	if req.BaseRedirectURL == "" {
+		sendAPIResponse(w, r, nil, "base redirect url is required", http.StatusBadRequest)
+		return
+	}
+	if req.ClientSecret == redactedSecret {
+		configs, err := dataprovider.GetConfigs()
+		if err != nil {
+			sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
+			return
+		}
+		configs.SetNilsToEmpty()
+		if err := configs.SMTP.TryDecrypt(); err == nil {
+			req.OAuth2Config.ClientSecret = configs.SMTP.OAuth2.ClientSecret.GetPayload()
+		}
+	}
+	cfg := req.OAuth2Config.GetOAuth2()
+	cfg.RedirectURL = req.BaseRedirectURL + webOAuth2RedirectPath
+	clientSecret := kms.NewPlainSecret(cfg.ClientSecret)
+	clientSecret.SetAdditionalData(xid.New().String())
+	pendingAuth := newOAuth2PendingAuth(req.Provider, cfg.RedirectURL, cfg.ClientID, clientSecret)
+	oauth2Mgr.addPendingAuth(pendingAuth)
+	stateToken := createOAuth2Token(pendingAuth.State, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if stateToken == "" {
+		sendAPIResponse(w, r, nil, "unable to create state token", http.StatusInternalServerError)
+		return
+	}
+	u := cfg.AuthCodeURL(stateToken, oauth2.AccessTypeOffline)
+	sendAPIResponse(w, r, nil, u, http.StatusOK)
+}

+ 45 - 0
internal/httpd/auth_utils.go

@@ -40,6 +40,7 @@ const (
 	tokenAudienceAPI              tokenAudience = "API"
 	tokenAudienceAPIUser          tokenAudience = "APIUser"
 	tokenAudienceCSRF             tokenAudience = "CSRF"
+	tokenAudienceOAuth2           tokenAudience = "OAuth2"
 )
 
 type tokenValidation = int
@@ -417,3 +418,47 @@ func verifyCSRFToken(tokenString, ip string) error {
 
 	return nil
 }
+
+func createOAuth2Token(state, ip string) string {
+	claims := make(map[string]any)
+	now := time.Now().UTC()
+
+	claims[jwt.JwtIDKey] = state
+	claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
+	claims[jwt.ExpirationKey] = now.Add(3 * time.Minute)
+	claims[jwt.AudienceKey] = []string{tokenAudienceOAuth2, ip}
+
+	_, tokenString, err := csrfTokenAuth.Encode(claims)
+	if err != nil {
+		logger.Debug(logSender, "", "unable to create OAuth2 token: %v", err)
+		return ""
+	}
+	return tokenString
+}
+
+func verifyOAuth2Token(tokenString, ip string) (string, error) {
+	token, err := jwtauth.VerifyToken(csrfTokenAuth, tokenString)
+	if err != nil || token == nil {
+		logger.Debug(logSender, "", "error validating OAuth2 token %q: %v", tokenString, err)
+		return "", fmt.Errorf("unable to verify OAuth2 state: %v", err)
+	}
+
+	if !util.Contains(token.Audience(), tokenAudienceOAuth2) {
+		logger.Debug(logSender, "", "error validating OAuth2 token audience")
+		return "", errors.New("invalid OAuth2 state")
+	}
+
+	if tokenValidationMode != tokenValidationNoIPMatch {
+		if !util.Contains(token.Audience(), ip) {
+			logger.Debug(logSender, "", "error validating OAuth2 token IP audience")
+			return "", errors.New("invalid OAuth2 state")
+		}
+	}
+	if val, ok := token.Get(jwt.JwtIDKey); ok {
+		if state, ok := val.(string); ok {
+			return state, nil
+		}
+	}
+	logger.Debug(logSender, "", "jti not found in OAuth2 token")
+	return "", errors.New("invalid OAuth2 state")
+}

+ 8 - 0
internal/httpd/httpd.go

@@ -107,6 +107,8 @@ const (
 	webAdminLoginPathDefault              = "/web/admin/login"
 	webAdminOIDCLoginPathDefault          = "/web/admin/oidclogin"
 	webOIDCRedirectPathDefault            = "/web/oidc/redirect"
+	webOAuth2RedirectPathDefault          = "/web/oauth2/redirect"
+	webOAuth2TokenPathDefault             = "/web/admin/oauth2/token"
 	webAdminTwoFactorPathDefault          = "/web/admin/twofactor"
 	webAdminTwoFactorRecoveryPathDefault  = "/web/admin/twofactor-recovery"
 	webLogoutPathDefault                  = "/web/admin/logout"
@@ -201,6 +203,8 @@ var (
 	webBaseAdminPath               string
 	webBaseClientPath              string
 	webOIDCRedirectPath            string
+	webOAuth2RedirectPath          string
+	webOAuth2TokenPath             string
 	webAdminSetupPath              string
 	webAdminOIDCLoginPath          string
 	webAdminLoginPath              string
@@ -919,6 +923,7 @@ func (c *Conf) Initialize(configDir string, isShared int) error {
 	configurationDir = configDir
 	resetCodesMgr = newResetCodeManager(isShared)
 	oidcMgr = newOIDCManager(isShared)
+	oauth2Mgr = newOAuth2Manager(isShared)
 	staticFilesPath := util.FindSharedDataPath(c.StaticFilesPath, configDir)
 	templatesPath := util.FindSharedDataPath(c.TemplatesPath, configDir)
 	openAPIPath := util.FindSharedDataPath(c.OpenAPIPath, configDir)
@@ -1100,6 +1105,8 @@ func updateWebAdminURLs(baseURL string) {
 	webBasePath = path.Join(baseURL, webBasePathDefault)
 	webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault)
 	webOIDCRedirectPath = path.Join(baseURL, webOIDCRedirectPathDefault)
+	webOAuth2RedirectPath = path.Join(baseURL, webOAuth2RedirectPathDefault)
+	webOAuth2TokenPath = path.Join(baseURL, webOAuth2TokenPathDefault)
 	webAdminSetupPath = path.Join(baseURL, webAdminSetupPathDefault)
 	webAdminLoginPath = path.Join(baseURL, webAdminLoginPathDefault)
 	webAdminOIDCLoginPath = path.Join(baseURL, webAdminOIDCLoginPathDefault)
@@ -1176,6 +1183,7 @@ func startCleanupTicker(duration time.Duration) {
 				resetCodesMgr.Cleanup()
 				if counter%2 == 0 {
 					oidcMgr.cleanup()
+					oauth2Mgr.cleanup()
 				}
 			}
 		}

+ 104 - 1
internal/httpd/httpd_test.go

@@ -171,6 +171,7 @@ const (
 	webAdminRolePath               = "/web/admin/role"
 	webEventsPath                  = "/web/admin/events"
 	webConfigsPath                 = "/web/admin/configs"
+	webOAuth2TokenPath             = "/web/admin/oauth2/token"
 	webBasePathClient              = "/web/client"
 	webClientLoginPath             = "/web/client/login"
 	webClientFilesPath             = "/web/client/files"
@@ -1432,6 +1433,37 @@ func TestConfigs(t *testing.T) {
 	err = dataprovider.UpdateConfigs(&configs, "", "", "")
 	assert.ErrorIs(t, err, util.ErrValidation)
 
+	configs = dataprovider.Configs{
+		SMTP: &dataprovider.SMTPConfigs{
+			Host:       "mail.example.com",
+			Port:       587,
+			User:       "[email protected]",
+			AuthType:   3,
+			Encryption: 2,
+			OAuth2: dataprovider.SMTPOAuth2{
+				Provider: 1,
+				Tenant:   "",
+				ClientID: "",
+			},
+		},
+	}
+	err = dataprovider.UpdateConfigs(&configs, "", "", "")
+	if assert.ErrorIs(t, err, util.ErrValidation) {
+		assert.Contains(t, err.Error(), "smtp oauth2: client id is required")
+	}
+	configs.SMTP.OAuth2 = dataprovider.SMTPOAuth2{
+		Provider:     1,
+		ClientID:     "client id",
+		ClientSecret: kms.NewPlainSecret("client secret"),
+		RefreshToken: kms.NewPlainSecret("refresh token"),
+	}
+	err = dataprovider.UpdateConfigs(&configs, "", "", "")
+	assert.NoError(t, err)
+	configs, err = dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Equal(t, 3, configs.SMTP.AuthType)
+	assert.Equal(t, 1, configs.SMTP.OAuth2.Provider)
+
 	err = dataprovider.UpdateConfigs(nil, "", "", "")
 	assert.NoError(t, err)
 }
@@ -9396,7 +9428,7 @@ func TestSMTPConfig(t *testing.T) {
 	tokenHeader := "X-CSRF-TOKEN"
 	webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
-	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
 	assert.NoError(t, err)
 	req, err := http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer([]byte("{")))
 	assert.NoError(t, err)
@@ -9453,6 +9485,22 @@ func TestSMTPConfig(t *testing.T) {
 	checkResponseCode(t, http.StatusInternalServerError, rr)
 	assert.Contains(t, rr.Body.String(), "server does not support SMTP AUTH")
 
+	testReq["password"] = ""
+	testReq["auth_type"] = 3
+	testReq["oauth2"] = smtp.OAuth2Config{
+		ClientSecret: redactedSecret,
+		RefreshToken: redactedSecret,
+	}
+	asJSON, err = json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set(tokenHeader, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "smtp oauth2: client id is required")
+
 	err = dataprovider.UpdateConfigs(nil, "", "", "")
 	assert.NoError(t, err)
 	smtpCfg = smtp.Config{}
@@ -9460,6 +9508,45 @@ func TestSMTPConfig(t *testing.T) {
 	require.NoError(t, err)
 }
 
+func TestOAuth2TokenRequest(t *testing.T) {
+	tokenHeader := "X-CSRF-TOKEN"
+	webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer([]byte("{")))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	req.Header.Set(tokenHeader, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	testReq := make(map[string]any)
+	testReq["client_secret"] = redactedSecret
+	asJSON, err := json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set(tokenHeader, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "base redirect url is required")
+
+	testReq["base_redirect_url"] = "http://localhost:8081"
+	asJSON, err = json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set(tokenHeader, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+}
+
 func TestMFAPermission(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
@@ -12684,6 +12771,9 @@ func TestWebConfigsMock(t *testing.T) {
 	form.Set("smtp_port", "a") // converted to 587
 	form.Set("smtp_auth", "1")
 	form.Set("smtp_encryption", "2")
+	form.Set("smtp_debug", "checked")
+	form.Set("smtp_oauth2_provider", "1")
+	form.Set("smtp_oauth2_client_id", "123")
 	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, webToken)
@@ -12702,6 +12792,8 @@ func TestWebConfigsMock(t *testing.T) {
 	assert.Equal(t, 587, configs.SMTP.Port)
 	assert.Equal(t, "Example <[email protected]>", configs.SMTP.From)
 	assert.Equal(t, defaultUsername, configs.SMTP.User)
+	assert.Equal(t, 1, configs.SMTP.Debug)
+	assert.Equal(t, "", configs.SMTP.OAuth2.ClientID)
 	err = configs.SMTP.Password.Decrypt()
 	assert.NoError(t, err)
 	assert.Equal(t, defaultPassword, configs.SMTP.Password.GetPayload())
@@ -23971,6 +24063,17 @@ func TestProviderClosedMock(t *testing.T) {
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
 
+	testReq["base_redirect_url"] = "http://localhost"
+	testReq["client_secret"] = redactedSecret
+	asJSON, err = json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
+
 	req, err = http.NewRequest(http.MethodGet, webConfigsPath, nil)
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, token)

+ 127 - 0
internal/httpd/internal_test.go

@@ -44,6 +44,7 @@ import (
 	"github.com/lestrrat-go/jwx/v2/jwt"
 	"github.com/rs/xid"
 	"github.com/sftpgo/sdk"
+	sdkkms "github.com/sftpgo/sdk/kms"
 	"github.com/sftpgo/sdk/plugin/notifier"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -971,6 +972,132 @@ func TestRetentionInvalidTokenClaims(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestUpdateSMTPSecrets(t *testing.T) {
+	currentConfigs := &dataprovider.SMTPConfigs{
+		OAuth2: dataprovider.SMTPOAuth2{
+			ClientSecret: kms.NewPlainSecret("client secret"),
+			RefreshToken: kms.NewPlainSecret("refresh token"),
+		},
+	}
+	redactedClientSecret := kms.NewPlainSecret("secret")
+	redactedRefreshToken := kms.NewPlainSecret("token")
+	redactedClientSecret.SetStatus(sdkkms.SecretStatusRedacted)
+	redactedRefreshToken.SetStatus(sdkkms.SecretStatusRedacted)
+	newConfigs := &dataprovider.SMTPConfigs{
+		Password: kms.NewPlainSecret("pwd"),
+		OAuth2: dataprovider.SMTPOAuth2{
+			ClientSecret: redactedClientSecret,
+			RefreshToken: redactedRefreshToken,
+		},
+	}
+	updateSMTPSecrets(newConfigs, currentConfigs)
+	assert.Nil(t, currentConfigs.Password)
+	assert.NotNil(t, newConfigs.Password)
+	assert.Equal(t, currentConfigs.OAuth2.ClientSecret, newConfigs.OAuth2.ClientSecret)
+	assert.Equal(t, currentConfigs.OAuth2.RefreshToken, newConfigs.OAuth2.RefreshToken)
+
+	clientSecret := kms.NewPlainSecret("plain secret")
+	refreshToken := kms.NewPlainSecret("plain token")
+	newConfigs = &dataprovider.SMTPConfigs{
+		Password: kms.NewPlainSecret("pwd"),
+		OAuth2: dataprovider.SMTPOAuth2{
+			ClientSecret: clientSecret,
+			RefreshToken: refreshToken,
+		},
+	}
+	updateSMTPSecrets(newConfigs, currentConfigs)
+	assert.Equal(t, clientSecret, newConfigs.OAuth2.ClientSecret)
+	assert.Equal(t, refreshToken, newConfigs.OAuth2.RefreshToken)
+}
+
+func TestOAuth2Redirect(t *testing.T) {
+	server := httpdServer{}
+	server.initializeRouter()
+
+	rr := httptest.NewRecorder()
+	req, err := http.NewRequest(http.MethodGet, webOAuth2RedirectPath+"?state=invalid", nil)
+	assert.NoError(t, err)
+	server.handleOAuth2TokenRedirect(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "token is unauthorized")
+
+	ip := "127.1.1.4"
+	tokenString := createOAuth2Token(xid.New().String(), ip)
+	rr = httptest.NewRecorder()
+	req, err = http.NewRequest(http.MethodGet, webOAuth2RedirectPath+"?state="+tokenString, nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = ip
+	server.handleOAuth2TokenRedirect(rr, req)
+	assert.Equal(t, http.StatusInternalServerError, rr.Code)
+	assert.Contains(t, rr.Body.String(), "no auth request found for the specified state")
+}
+
+func TestOAuth2Token(t *testing.T) {
+	// invalid token
+	_, err := verifyOAuth2Token("token", "")
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "unable to verify OAuth2 state")
+	}
+	// bad audience
+	claims := make(map[string]any)
+	now := time.Now().UTC()
+
+	claims[jwt.JwtIDKey] = xid.New().String()
+	claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
+	claims[jwt.ExpirationKey] = now.Add(tokenDuration)
+	claims[jwt.AudienceKey] = []string{tokenAudienceAPI}
+
+	_, tokenString, err := csrfTokenAuth.Encode(claims)
+	assert.NoError(t, err)
+	_, err = verifyOAuth2Token(tokenString, "")
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "invalid OAuth2 state")
+	}
+	// bad IP
+	tokenString = createOAuth2Token("state", "127.1.1.1")
+	_, err = verifyOAuth2Token(tokenString, "127.1.1.2")
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "invalid OAuth2 state")
+	}
+	// ok
+	state := xid.New().String()
+	tokenString = createOAuth2Token(state, "127.1.1.3")
+	s, err := verifyOAuth2Token(tokenString, "127.1.1.3")
+	assert.NoError(t, err)
+	assert.Equal(t, state, s)
+	// no jti
+	claims = make(map[string]any)
+
+	claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
+	claims[jwt.ExpirationKey] = now.Add(tokenDuration)
+	claims[jwt.AudienceKey] = []string{tokenAudienceOAuth2, "127.1.1.4"}
+	_, tokenString, err = csrfTokenAuth.Encode(claims)
+	assert.NoError(t, err)
+	_, err = verifyOAuth2Token(tokenString, "127.1.1.4")
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "invalid OAuth2 state")
+	}
+	// encode error
+	csrfTokenAuth = jwtauth.New("HT256", util.GenerateRandomBytes(32), nil)
+	tokenString = createOAuth2Token(xid.New().String(), "")
+	assert.Empty(t, tokenString)
+
+	server := httpdServer{}
+	server.initializeRouter()
+	rr := httptest.NewRecorder()
+	testReq := make(map[string]any)
+	testReq["base_redirect_url"] = "http://localhost:8082"
+	asJSON, err := json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	handleSMTPOAuth2TokenRequestPost(rr, req)
+	assert.Equal(t, http.StatusInternalServerError, rr.Code)
+	assert.Contains(t, rr.Body.String(), "unable to create state token")
+
+	csrfTokenAuth = jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil)
+}
+
 func TestCSRFToken(t *testing.T) {
 	// invalid token
 	err := verifyCSRFToken("token", "")

+ 170 - 0
internal/httpd/oauth2.go

@@ -0,0 +1,170 @@
+// Copyright (C) 2019-2023 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+package httpd
+
+import (
+	"encoding/json"
+	"errors"
+	"sync"
+	"time"
+
+	"github.com/rs/xid"
+
+	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+	"github.com/drakkan/sftpgo/v2/internal/kms"
+	"github.com/drakkan/sftpgo/v2/internal/logger"
+	"github.com/drakkan/sftpgo/v2/internal/util"
+)
+
+var (
+	oauth2Mgr oauth2Manager
+)
+
+func newOAuth2Manager(isShared int) oauth2Manager {
+	if isShared == 1 {
+		logger.Info(logSender, "", "using provider OAuth2 manager")
+		return &dbOAuth2Manager{}
+	}
+	logger.Info(logSender, "", "using memory OAuth2 manager")
+	return &memoryOAuth2Manager{
+		pendingAuths: make(map[string]oauth2PendingAuth),
+	}
+}
+
+type oauth2PendingAuth struct {
+	State        string      `json:"state"`
+	Provider     int         `json:"provider"`
+	ClientID     string      `json:"client_id"`
+	ClientSecret *kms.Secret `json:"client_secret"`
+	RedirectURL  string      `json:"redirect_url"`
+	IssuedAt     int64       `json:"issued_at"`
+}
+
+func newOAuth2PendingAuth(provider int, redirectURL, clientID string, clientSecret *kms.Secret) oauth2PendingAuth {
+	return oauth2PendingAuth{
+		State:        xid.New().String(),
+		Provider:     provider,
+		ClientID:     clientID,
+		ClientSecret: clientSecret,
+		RedirectURL:  redirectURL,
+		IssuedAt:     util.GetTimeAsMsSinceEpoch(time.Now()),
+	}
+}
+
+type oauth2Manager interface {
+	addPendingAuth(pendingAuth oauth2PendingAuth)
+	removePendingAuth(state string)
+	getPendingAuth(state string) (oauth2PendingAuth, error)
+	cleanup()
+}
+
+type memoryOAuth2Manager struct {
+	mu           sync.RWMutex
+	pendingAuths map[string]oauth2PendingAuth
+}
+
+func (o *memoryOAuth2Manager) addPendingAuth(pendingAuth oauth2PendingAuth) {
+	o.mu.Lock()
+	defer o.mu.Unlock()
+
+	o.pendingAuths[pendingAuth.State] = pendingAuth
+}
+
+func (o *memoryOAuth2Manager) removePendingAuth(state string) {
+	o.mu.Lock()
+	defer o.mu.Unlock()
+
+	delete(o.pendingAuths, state)
+}
+
+func (o *memoryOAuth2Manager) getPendingAuth(state string) (oauth2PendingAuth, error) {
+	o.mu.RLock()
+	defer o.mu.RUnlock()
+
+	authReq, ok := o.pendingAuths[state]
+	if !ok {
+		return oauth2PendingAuth{}, errors.New("oauth2: no auth request found for the specified state")
+	}
+	diff := util.GetTimeAsMsSinceEpoch(time.Now()) - authReq.IssuedAt
+	if diff > authStateValidity {
+		return oauth2PendingAuth{}, errors.New("oauth2: auth request is too old")
+	}
+	return authReq, nil
+}
+
+func (o *memoryOAuth2Manager) cleanup() {
+	logger.Debug(logSender, "", "oauth2 manager cleanup")
+	o.mu.Lock()
+	defer o.mu.Unlock()
+
+	for k, auth := range o.pendingAuths {
+		diff := util.GetTimeAsMsSinceEpoch(time.Now()) - auth.IssuedAt
+		// remove old pending auth requests
+		if diff < 0 || diff > authStateValidity {
+			delete(o.pendingAuths, k)
+		}
+	}
+}
+
+type dbOAuth2Manager struct{}
+
+func (o *dbOAuth2Manager) addPendingAuth(pendingAuth oauth2PendingAuth) {
+	if err := pendingAuth.ClientSecret.Encrypt(); err != nil {
+		logger.Error(logSender, "", "unable to encrypt oauth2 secret: %v", err)
+		return
+	}
+	session := dataprovider.Session{
+		Key:       pendingAuth.State,
+		Data:      pendingAuth,
+		Type:      dataprovider.SessionTypeOAuth2Auth,
+		Timestamp: pendingAuth.IssuedAt + authStateValidity,
+	}
+	dataprovider.AddSharedSession(session) //nolint:errcheck
+}
+
+func (o *dbOAuth2Manager) removePendingAuth(state string) {
+	dataprovider.DeleteSharedSession(state) //nolint:errcheck
+}
+
+func (o *dbOAuth2Manager) getPendingAuth(state string) (oauth2PendingAuth, error) {
+	session, err := dataprovider.GetSharedSession(state)
+	if err != nil {
+		return oauth2PendingAuth{}, errors.New("oauth2: unable to get the auth request for the specified state")
+	}
+	if session.Timestamp < util.GetTimeAsMsSinceEpoch(time.Now()) {
+		// expired
+		return oauth2PendingAuth{}, errors.New("oauth2: auth request is too old")
+	}
+	return o.decodePendingAuthData(session.Data)
+}
+
+func (o *dbOAuth2Manager) decodePendingAuthData(data any) (oauth2PendingAuth, error) {
+	if val, ok := data.([]byte); ok {
+		authReq := oauth2PendingAuth{}
+		err := json.Unmarshal(val, &authReq)
+		if err != nil {
+			return authReq, err
+		}
+		err = authReq.ClientSecret.TryDecrypt()
+		return authReq, err
+	}
+	logger.Error(logSender, "", "invalid oauth2 auth request data type %T", data)
+	return oauth2PendingAuth{}, errors.New("oauth2: invalid auth request data")
+}
+
+func (o *dbOAuth2Manager) cleanup() {
+	logger.Debug(logSender, "", "oauth2 manager cleanup")
+	dataprovider.CleanupSharedSessions(dataprovider.SessionTypeOAuth2Auth, time.Now()) //nolint:errcheck
+}

+ 135 - 0
internal/httpd/oauth2_test.go

@@ -0,0 +1,135 @@
+// Copyright (C) 2019-2023 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+package httpd
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/rs/xid"
+	sdkkms "github.com/sftpgo/sdk/kms"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+	"github.com/drakkan/sftpgo/v2/internal/kms"
+	"github.com/drakkan/sftpgo/v2/internal/util"
+)
+
+func TestMemoryOAuth2Manager(t *testing.T) {
+	mgr := newOAuth2Manager(0)
+	m, ok := mgr.(*memoryOAuth2Manager)
+	require.True(t, ok)
+	require.Len(t, m.pendingAuths, 0)
+	_, err := m.getPendingAuth(xid.New().String())
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "no auth request found")
+	auth := newOAuth2PendingAuth(1, "https://...", "cid", kms.NewPlainSecret("mysecret"))
+	m.addPendingAuth(auth)
+	require.Len(t, m.pendingAuths, 1)
+	a, err := m.getPendingAuth(auth.State)
+	assert.NoError(t, err)
+	assert.Equal(t, auth.State, a.State)
+	assert.Equal(t, sdkkms.SecretStatusPlain, a.ClientSecret.GetStatus())
+	m.removePendingAuth(auth.State)
+	_, err = m.getPendingAuth(auth.State)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "no auth request found")
+	require.Len(t, m.pendingAuths, 0)
+	state := xid.New().String()
+	auth = oauth2PendingAuth{
+		State:    state,
+		Provider: 1,
+		IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now()),
+	}
+	m.addPendingAuth(auth)
+	auth = oauth2PendingAuth{
+		State:    xid.New().String(),
+		Provider: 1,
+		IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-10 * time.Minute)),
+	}
+	m.addPendingAuth(auth)
+	require.Len(t, m.pendingAuths, 2)
+	_, err = m.getPendingAuth(auth.State)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "auth request is too old")
+	m.cleanup()
+	require.Len(t, m.pendingAuths, 1)
+	m.removePendingAuth(state)
+	require.Len(t, m.pendingAuths, 0)
+}
+
+func TestDbOAuth2Manager(t *testing.T) {
+	if !isSharedProviderSupported() {
+		t.Skip("this test it is not available with this provider")
+	}
+	mgr := newOAuth2Manager(1)
+	m, ok := mgr.(*dbOAuth2Manager)
+	require.True(t, ok)
+	_, err := m.getPendingAuth(xid.New().String())
+	require.Error(t, err)
+	auth := newOAuth2PendingAuth(1, "https://...", "client_id", kms.NewPlainSecret("my db secret"))
+	m.addPendingAuth(auth)
+	a, err := m.getPendingAuth(auth.State)
+	assert.NoError(t, err)
+	assert.Equal(t, sdkkms.SecretStatusPlain, a.ClientSecret.GetStatus())
+	session, err := dataprovider.GetSharedSession(auth.State)
+	assert.NoError(t, err)
+	authReq := oauth2PendingAuth{}
+	err = json.Unmarshal(session.Data.([]byte), &authReq)
+	assert.NoError(t, err)
+	assert.Equal(t, sdkkms.SecretStatusSecretBox, authReq.ClientSecret.GetStatus())
+	m.cleanup()
+	_, err = m.getPendingAuth(auth.State)
+	assert.NoError(t, err)
+	m.removePendingAuth(auth.State)
+	_, err = m.getPendingAuth(auth.State)
+	assert.Error(t, err)
+	auth = oauth2PendingAuth{
+		State:        xid.New().String(),
+		Provider:     1,
+		IssuedAt:     util.GetTimeAsMsSinceEpoch(time.Now().Add(-10 * time.Minute)),
+		ClientSecret: kms.NewPlainSecret("db secret"),
+	}
+	m.addPendingAuth(auth)
+	_, err = m.getPendingAuth(auth.State)
+	assert.Error(t, err)
+	_, err = dataprovider.GetSharedSession(auth.State)
+	assert.NoError(t, err)
+	m.cleanup()
+	_, err = dataprovider.GetSharedSession(auth.State)
+	assert.Error(t, err)
+	_, err = m.decodePendingAuthData("not a byte array")
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "invalid auth request data")
+	_, err = m.decodePendingAuthData([]byte("{not a json"))
+	require.Error(t, err)
+	// adding a request with a non plain secret will fail
+	auth = oauth2PendingAuth{
+		State:        xid.New().String(),
+		Provider:     1,
+		IssuedAt:     util.GetTimeAsMsSinceEpoch(time.Now().Add(-10 * time.Minute)),
+		ClientSecret: kms.NewPlainSecret("db secret"),
+	}
+	auth.ClientSecret.SetStatus(sdkkms.SecretStatusSecretBox)
+	m.addPendingAuth(auth)
+	_, err = dataprovider.GetSharedSession(auth.State)
+	assert.Error(t, err)
+	asJSON, err := json.Marshal(auth)
+	assert.NoError(t, err)
+	_, err = m.decodePendingAuthData(asJSON)
+	assert.Error(t, err)
+}

+ 3 - 0
internal/httpd/server.go

@@ -1573,6 +1573,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
 		if s.binding.OIDC.hasRoles() && !s.binding.isWebAdminOIDCLoginDisabled() {
 			s.router.Get(webAdminOIDCLoginPath, s.handleWebAdminOIDCLogin)
 		}
+		s.router.Get(webOAuth2RedirectPath, s.handleOAuth2TokenRedirect)
 		s.router.Get(webAdminSetupPath, s.handleWebAdminSetupGet)
 		s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)
 		if !s.binding.isWebAdminLoginFormDisabled() {
@@ -1745,6 +1746,8 @@ func (s *httpdServer) setupWebAdminRoutes() {
 			router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(webConfigsPath, s.handleWebConfigsPost)
 			router.With(s.checkPerm(dataprovider.PermAdminManageSystem), verifyCSRFHeader, s.refreshCookie).
 				Post(webConfigsPath+"/smtp/test", testSMTPConfig)
+			router.With(s.checkPerm(dataprovider.PermAdminManageSystem), verifyCSRFHeader, s.refreshCookie).
+				Post(webOAuth2TokenPath, handleSMTPOAuth2TokenRequestPost)
 		})
 	}
 }

+ 81 - 14
internal/httpd/webadmin.go

@@ -15,6 +15,7 @@
 package httpd
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"html/template"
@@ -393,10 +394,12 @@ type eventsPage struct {
 
 type configsPage struct {
 	basePage
-	Configs        dataprovider.Configs
-	ConfigSection  int
-	RedactedSecret string
-	Error          string
+	Configs           dataprovider.Configs
+	ConfigSection     int
+	RedactedSecret    string
+	OAuth2TokenURL    string
+	OAuth2RedirectURL string
+	Error             string
 }
 
 type messagePage struct {
@@ -904,16 +907,20 @@ func (s *httpdServer) renderConfigsPage(w http.ResponseWriter, r *http.Request,
 	configs.SetNilsToEmpty()
 	if configs.SMTP.Port == 0 {
 		configs.SMTP.Port = 587
+		configs.SMTP.AuthType = 1
+		configs.SMTP.Encryption = 2
 	}
 	if configs.ACME.HTTP01Challenge.Port == 0 {
 		configs.ACME.HTTP01Challenge.Port = 80
 	}
 	data := configsPage{
-		basePage:       s.getBasePageData(pageConfigsTitle, webConfigsPath, r),
-		Configs:        configs,
-		ConfigSection:  section,
-		RedactedSecret: redactedSecret,
-		Error:          error,
+		basePage:          s.getBasePageData(pageConfigsTitle, webConfigsPath, r),
+		Configs:           configs,
+		ConfigSection:     section,
+		RedactedSecret:    redactedSecret,
+		OAuth2TokenURL:    webOAuth2TokenPath,
+		OAuth2RedirectURL: webOAuth2RedirectPath,
+		Error:             error,
 	}
 
 	renderAdminTemplate(w, templateConfigs, data)
@@ -2639,6 +2646,10 @@ func getSMTPConfigsFromPostFields(r *http.Request) *dataprovider.SMTPConfigs {
 	if r.Form.Get("smtp_debug") != "" {
 		debug = 1
 	}
+	oauth2Provider := 0
+	if r.Form.Get("smtp_oauth2_provider") == "1" {
+		oauth2Provider = 1
+	}
 	return &dataprovider.SMTPConfigs{
 		Host:       r.Form.Get("smtp_host"),
 		Port:       port,
@@ -2649,6 +2660,13 @@ func getSMTPConfigsFromPostFields(r *http.Request) *dataprovider.SMTPConfigs {
 		Encryption: encryption,
 		Domain:     r.Form.Get("smtp_domain"),
 		Debug:      debug,
+		OAuth2: dataprovider.SMTPOAuth2{
+			Provider:     oauth2Provider,
+			Tenant:       strings.TrimSpace(r.Form.Get("smtp_oauth2_tenant")),
+			ClientID:     strings.TrimSpace(r.Form.Get("smtp_oauth2_client_id")),
+			ClientSecret: getSecretFromFormField(r, "smtp_oauth2_client_secret"),
+			RefreshToken: getSecretFromFormField(r, "smtp_oauth2_refresh_token"),
+		},
 	}
 }
 
@@ -4137,9 +4155,7 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
 	case "smtp_submit":
 		configSection = 3
 		smtpConfigs := getSMTPConfigsFromPostFields(r)
-		if smtpConfigs.Password.IsNotPlainAndNotEmpty() {
-			smtpConfigs.Password = configs.SMTP.Password
-		}
+		updateSMTPSecrets(smtpConfigs, configs.SMTP)
 		configs.SMTP = smtpConfigs
 	default:
 		s.renderBadRequestPage(w, r, errors.New("unsupported form action"))
@@ -4152,13 +4168,64 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
 		return
 	}
 	if configSection == 3 {
-		err := configs.SMTP.Password.TryDecrypt()
+		err := configs.SMTP.TryDecrypt()
 		if err == nil {
 			smtp.Activate(configs.SMTP)
 		} else {
-			logger.Error(logSender, "", "unable to decrypt SMTP password, cannot activate configuration")
+			logger.Error(logSender, "", "unable to decrypt SMTP configuration, cannot activate configuration: %v", err)
 		}
 	}
 	s.renderMessagePage(w, r, "Configurations updated", "", http.StatusOK, nil,
 		"Configurations has been successfully updated")
 }
+
+func (s *httpdServer) handleOAuth2TokenRedirect(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	stateToken := r.URL.Query().Get("state")
+	errorTitle := "Unable to complete OAuth2 flow"
+	successTitle := "OAuth2 flow completed"
+
+	state, err := verifyOAuth2Token(stateToken, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err != nil {
+		s.renderMessagePage(w, r, errorTitle, "Invalid auth request:", http.StatusBadRequest, err, "")
+		return
+	}
+
+	defer oauth2Mgr.removePendingAuth(state)
+
+	pendingAuth, err := oauth2Mgr.getPendingAuth(state)
+	if err != nil {
+		s.renderMessagePage(w, r, errorTitle, "Unable to validate auth request:", http.StatusInternalServerError, err, "")
+		return
+	}
+	oauth2Config := smtp.OAuth2Config{
+		Provider:     pendingAuth.Provider,
+		ClientID:     pendingAuth.ClientID,
+		ClientSecret: pendingAuth.ClientSecret.GetPayload(),
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+
+	cfg := oauth2Config.GetOAuth2()
+	cfg.RedirectURL = pendingAuth.RedirectURL
+	token, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
+	if err != nil {
+		s.renderMessagePage(w, r, errorTitle, "Unable to get token:", http.StatusInternalServerError, err, "")
+		return
+	}
+	s.renderMessagePage(w, r, successTitle, "", http.StatusOK, nil,
+		fmt.Sprintf("Copy the following string, without the quotes, into your SMTP OAuth2 Token configuration: %q", token.RefreshToken))
+}
+
+func updateSMTPSecrets(newConfigs, currentConfigs *dataprovider.SMTPConfigs) {
+	if newConfigs.Password.IsNotPlainAndNotEmpty() {
+		newConfigs.Password = currentConfigs.Password
+	}
+	if newConfigs.OAuth2.ClientSecret.IsNotPlainAndNotEmpty() {
+		newConfigs.OAuth2.ClientSecret = currentConfigs.OAuth2.ClientSecret
+	}
+	if newConfigs.OAuth2.RefreshToken.IsNotPlainAndNotEmpty() {
+		newConfigs.OAuth2.RefreshToken = currentConfigs.OAuth2.RefreshToken
+	}
+}

+ 165 - 0
internal/smtp/oauth2.go

@@ -0,0 +1,165 @@
+// Copyright (C) 2019-2023 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+// Package smtp provides supports for sending emails
+package smtp
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+
+	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/google"
+	"golang.org/x/oauth2/microsoft"
+
+	"github.com/drakkan/sftpgo/v2/internal/logger"
+	"github.com/drakkan/sftpgo/v2/internal/util"
+)
+
+// Supported OAuth2 providers
+const (
+	OAuth2ProviderGoogle = iota
+	OAuth2ProviderMicrosoft
+)
+
+var supportedOAuth2Providers = []int{OAuth2ProviderGoogle, OAuth2ProviderMicrosoft}
+
+// OAuth2Config defines OAuth2 settings
+type OAuth2Config struct {
+	Provider int `json:"provider" mapstructure:"provider"`
+	// Tenant for Microsoft provider, if empty "common" is used
+	Tenant string `json:"tenant" mapstructure:"tenant"`
+	// ClientID is the application's ID
+	ClientID string `json:"client_id" mapstructure:"client_id"`
+	// ClientSecret is the application's secret
+	ClientSecret string `json:"client_secret" mapstructure:"client_secret"`
+	// Token to use to get/renew access tokens
+	RefreshToken string `json:"refresh_token" mapstructure:"refresh_token"`
+	mu           *sync.RWMutex
+	config       *oauth2.Config
+	accessToken  *oauth2.Token
+}
+
+// Validate validates and initializes the configuration
+func (c *OAuth2Config) Validate() error {
+	if !util.Contains(supportedOAuth2Providers, c.Provider) {
+		return fmt.Errorf("smtp oauth2: unsupported provider %d", c.Provider)
+	}
+	if c.ClientID == "" {
+		return errors.New("smtp oauth2: client id is required")
+	}
+	if c.ClientSecret == "" {
+		return errors.New("smtp oauth2: client secret is required")
+	}
+	if c.RefreshToken == "" {
+		return errors.New("smtp oauth2: refresh token is required")
+	}
+	c.initialize()
+	return nil
+}
+
+func (c *OAuth2Config) isEqual(other *OAuth2Config) bool {
+	if c.Provider != other.Provider {
+		return false
+	}
+	if c.Tenant != other.Tenant {
+		return false
+	}
+	if c.ClientID != other.ClientID {
+		return false
+	}
+	if c.ClientSecret != other.ClientSecret {
+		return false
+	}
+	if c.RefreshToken != other.RefreshToken {
+		return false
+	}
+	return true
+}
+
+func (c *OAuth2Config) getAccessToken() (string, error) {
+	c.mu.RLock()
+	if c.accessToken.Expiry.After(time.Now().Add(30 * time.Second)) {
+		accessToken := c.accessToken.AccessToken
+		c.mu.RUnlock()
+
+		return accessToken, nil
+	}
+	logger.Debug(logSender, "", "renew oauth2 token required, current token expires at %s", c.accessToken.Expiry)
+	token := new(oauth2.Token)
+	*token = *c.accessToken
+	c.mu.RUnlock()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+
+	newToken, err := c.config.TokenSource(ctx, token).Token()
+	if err != nil {
+		logger.Error(logSender, "", "unable to get new token: %v", err)
+		return "", err
+	}
+	accessToken := newToken.AccessToken
+	refreshToken := newToken.RefreshToken
+	if refreshToken != "" && refreshToken != token.RefreshToken {
+		c.mu.Lock()
+		c.RefreshToken = refreshToken
+		c.accessToken = newToken
+		c.mu.Unlock()
+
+		logger.Debug(logSender, "", "oauth2 refresh token changed")
+		go updateRefreshToken(refreshToken)
+	}
+	if accessToken != token.AccessToken {
+		c.mu.Lock()
+		c.accessToken = newToken
+		c.mu.Unlock()
+
+		logger.Debug(logSender, "", "new oauth2 token saved, expires at %s", c.accessToken.Expiry)
+	}
+	return accessToken, nil
+}
+
+func (c *OAuth2Config) initialize() {
+	c.mu = new(sync.RWMutex)
+	c.config = c.GetOAuth2()
+	c.accessToken = &oauth2.Token{
+		TokenType:    "Bearer",
+		RefreshToken: c.RefreshToken,
+	}
+}
+
+// GetOAuth2 returns the oauth2 configuration for the provided parameters.
+func (c *OAuth2Config) GetOAuth2() *oauth2.Config {
+	var endpoint oauth2.Endpoint
+	var scopes []string
+
+	switch c.Provider {
+	case OAuth2ProviderMicrosoft:
+		endpoint = microsoft.AzureADEndpoint(c.Tenant)
+		scopes = []string{"offline_access", "https://outlook.office.com/SMTP.Send"}
+	default:
+		endpoint = google.Endpoint
+		scopes = []string{"https://mail.google.com/"}
+	}
+
+	return &oauth2.Config{
+		ClientID:     c.ClientID,
+		ClientSecret: c.ClientSecret,
+		Scopes:       scopes,
+		Endpoint:     endpoint,
+	}
+}

+ 49 - 6
internal/smtp/smtp.go

@@ -29,6 +29,7 @@ import (
 	"github.com/wneessen/go-mail"
 
 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+	"github.com/drakkan/sftpgo/v2/internal/kms"
 	"github.com/drakkan/sftpgo/v2/internal/logger"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 	"github.com/drakkan/sftpgo/v2/internal/version"
@@ -85,7 +86,15 @@ func (c *activeConfig) Set(cfg *dataprovider.SMTPConfigs) {
 			Encryption: cfg.Encryption,
 			Domain:     cfg.Domain,
 			Debug:      cfg.Debug,
+			OAuth2: OAuth2Config{
+				Provider:     cfg.OAuth2.Provider,
+				Tenant:       cfg.OAuth2.Tenant,
+				ClientID:     cfg.OAuth2.ClientID,
+				ClientSecret: cfg.OAuth2.ClientSecret.GetPayload(),
+				RefreshToken: cfg.OAuth2.RefreshToken.GetPayload(),
+			},
 		}
+		config.OAuth2.initialize()
 	}
 
 	c.Lock()
@@ -159,6 +168,7 @@ type Config struct {
 	// 0 Plain
 	// 1 Login
 	// 2 CRAM-MD5
+	// 3 OAuth2
 	AuthType int `json:"auth_type" mapstructure:"auth_type"`
 	// 0 no encryption
 	// 1 TLS
@@ -171,6 +181,8 @@ type Config struct {
 	TemplatesPath string `json:"templates_path" mapstructure:"templates_path"`
 	// Set to 1 to enable debug logs
 	Debug int `json:"debug" mapstructure:"debug"`
+	// OAuth2 related settings
+	OAuth2 OAuth2Config `json:"oauth2" mapstructure:"oauth2"`
 }
 
 func (c *Config) isEqual(other *Config) bool {
@@ -201,21 +213,24 @@ func (c *Config) isEqual(other *Config) bool {
 	if c.Debug != other.Debug {
 		return false
 	}
-	return true
+	return c.OAuth2.isEqual(&other.OAuth2)
 }
 
 func (c *Config) validate() error {
 	if c.Port <= 0 || c.Port > 65535 {
 		return fmt.Errorf("smtp: invalid port %d", c.Port)
 	}
-	if c.AuthType < 0 || c.AuthType > 2 {
+	if c.AuthType < 0 || c.AuthType > 3 {
 		return fmt.Errorf("smtp: invalid auth type %d", c.AuthType)
 	}
 	if c.Encryption < 0 || c.Encryption > 2 {
 		return fmt.Errorf("smtp: invalid encryption %d", c.Encryption)
 	}
 	if c.From == "" && c.User == "" {
-		return fmt.Errorf(`smtp: from address and user cannot both be empty`)
+		return errors.New(`smtp: from address and user cannot both be empty`)
+	}
+	if c.AuthType == 3 {
+		return c.OAuth2.Validate()
 	}
 	return nil
 }
@@ -283,6 +298,8 @@ func (c *Config) getMailClientOptions() []mail.Option {
 			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthLogin))
 		case 2:
 			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
+		case 3:
+			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthXOAUTH2))
 		default:
 			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthPlain))
 		}
@@ -341,6 +358,13 @@ func (c *Config) getSMTPClientAndMsg(to, bcc []string, subject, body string, con
 	if err != nil {
 		return nil, nil, fmt.Errorf("unable to create mail client: %w", err)
 	}
+	if c.AuthType == 3 {
+		token, err := c.OAuth2.getAccessToken()
+		if err != nil {
+			return nil, nil, fmt.Errorf("unable to get oauth2 access token: %w", err)
+		}
+		client.SetPassword(token)
+	}
 	return client, msg, nil
 }
 
@@ -402,10 +426,29 @@ func loadConfigFromProvider() error {
 		return fmt.Errorf("smtp: unable to load config from provider: %w", err)
 	}
 	configs.SetNilsToEmpty()
-	if err := configs.SMTP.Password.TryDecrypt(); err != nil {
-		logger.Error(logSender, "", "unable to decrypt password: %v", err)
-		return fmt.Errorf("smtp: unable to decrypt password: %w", err)
+	if err := configs.SMTP.TryDecrypt(); err != nil {
+		logger.Error(logSender, "", "unable to decrypt smtp config: %v", err)
+		return fmt.Errorf("smtp: unable to decrypt smtp config: %w", err)
 	}
 	config.Set(configs.SMTP)
 	return nil
 }
+
+func updateRefreshToken(token string) {
+	configs, err := dataprovider.GetConfigs()
+	if err != nil {
+		logger.Error(logSender, "", "unable to load config from provider, updating refresh token not possible: %v", err)
+		return
+	}
+	configs.SetNilsToEmpty()
+	if configs.SMTP.IsEmpty() {
+		logger.Warn(logSender, "", "unable to update refresh token, smtp not configured in the data provider")
+		return
+	}
+	configs.SMTP.OAuth2.RefreshToken = kms.NewPlainSecret(token)
+	if err := dataprovider.UpdateConfigs(&configs, dataprovider.ActionExecutorSystem, "", ""); err != nil {
+		logger.Error(logSender, "", "unable to save new refresh token: %v", err)
+		return
+	}
+	logger.Info(logSender, "", "refresh token updated")
+}

+ 8 - 1
sftpgo.json

@@ -407,7 +407,14 @@
     "encryption": 0,
     "domain": "",
     "templates_path": "templates",
-    "debug": 0
+    "debug": 0,
+    "oauth2": {
+      "provider": 0,
+      "tenant": "",
+      "client_id": "",
+      "client_secret": "",
+      "refresh_token": ""
+    }
   },
   "plugins": []
 }

+ 174 - 2
templates/webadmin/configs.html

@@ -239,10 +239,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                             <div class="form-group row">
                                 <label for="idSMTPAuth" class="col-sm-2 col-form-label">Auth</label>
                                 <div class="col-sm-3">
-                                    <select class="form-control selectpicker" id="idSMTPAuth" name="smtp_auth">
+                                    <select class="form-control selectpicker" id="idSMTPAuth" name="smtp_auth" onchange="onSMTPAuthChanged(this.value)">
                                         <option value="0" {{if eq .Configs.SMTP.AuthType 0}}selected{{end}}>Plain</option>
                                         <option value="1" {{if eq .Configs.SMTP.AuthType 1}}selected{{end}}>Login</option>
                                         <option value="2" {{if eq .Configs.SMTP.AuthType 2}}selected{{end}}>CRAM-MD5</option>
+                                        <option value="3" {{if eq .Configs.SMTP.AuthType 3}}selected{{end}}>OAuth2</option>
                                     </select>
                                 </div>
                                 <div class="col-sm-2"></div>
@@ -256,6 +257,59 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                                 </div>
                             </div>
 
+                            <div class="form-group row smtp-oauth2">
+                                <label for="idSMTPOAuth2Provider" class="col-sm-2 col-form-label">OAuth2 provider</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idSMTPOAuth2Provider" name="smtp_oauth2_provider"
+                                        onchange="onSMTPOAuth2ProviderChanged(this.value)" aria-describedby="smtpOauth2ProviderHelpBlock">
+                                        <option value="0" {{if eq .Configs.SMTP.OAuth2.Provider 0}}selected{{end}}>Google</option>
+                                        <option value="1" {{if eq .Configs.SMTP.OAuth2.Provider 1}}selected{{end}}>Microsoft</option>
+                                    </select>
+                                    <small id="smtpOauth2ProviderHelpBlock" class="form-text text-muted">
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row smtp-oauth2 smtp-oauth2-microsoft">
+                                <label for="idSMTPOauth2Tenant" class="col-sm-2 col-form-label">OAuth2 Tenant</label>
+                                <div class="col-sm-10">
+                                    <input type="text" class="form-control" id="idSMTPOauth2Tenant" name="smtp_oauth2_tenant" placeholder=""
+                                        value="{{.Configs.SMTP.OAuth2.Tenant}}" aria-describedby="smtpOauth2TenantHelpBlock">
+                                    <small id="smtpOauth2TenantHelpBlock" class="form-text text-muted">
+                                        Azure Active Directory tenant. Typical values are "common", "organizations", "consumers" or tenant identifier.
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row smtp-oauth2">
+                                <label for="idSMTPOauth2ClientID" class="col-sm-2 col-form-label">OAuth2 Client ID</label>
+                                <div class="col-sm-10">
+                                    <input type="text" class="form-control" id="idSMTPOauth2ClientID" name="smtp_oauth2_client_id" placeholder=""
+                                        value="{{.Configs.SMTP.OAuth2.ClientID}}" spellcheck="false">
+                                </div>
+                            </div>
+
+                            <div class="form-group row smtp-oauth2">
+                                <label for="idSMTPOAuth2ClientSecret" class="col-sm-2 col-form-label">OAuth2 Client secret</label>
+                                <div class="col-sm-10">
+                                    <input type="password" class="form-control" id="idSMTPOAuth2ClientSecret" name="smtp_oauth2_client_secret" placeholder="" autocomplete="new-password" spellcheck="false"
+                                        value="{{if .Configs.SMTP.OAuth2.ClientSecret.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.OAuth2.ClientSecret.GetPayload}}{{end}}">
+                                </div>
+                            </div>
+
+                            <div class="form-group row smtp-oauth2">
+                                <label for="idSMTPOAuth2RefreshToken" class="col-sm-2 col-form-label">OAuth2 Token</label>
+                                <div class="col-sm-10">
+                                    <div class="input-group">
+                                        <input type="password" class="form-control" id="idSMTPOAuth2RefreshToken" name="smtp_oauth2_refresh_token" placeholder="" autocomplete="new-password" spellcheck="false"
+                                            value="{{if .Configs.SMTP.OAuth2.RefreshToken.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.OAuth2.RefreshToken.GetPayload}}{{end}}">
+                                        <div class="input-group-append">
+                                            <button class="btn btn-secondary px-5" onclick="getRefreshToken(event);">Get</button>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+
                             <div class="form-group row">
                                 <label for="idSMTPFrom" class="col-sm-2 col-form-label">From</label>
                                 <div class="col-sm-10">
@@ -341,6 +395,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         </div>
     </div>
 </div>
+
+<div class="modal fade" id="smtpOAuthFlowModal" tabindex="-1" role="dialog" aria-labelledby="smtpOAuthFlowModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="smtpOAuthFlowModal">
+                    OAuth2 flow
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <div id="oauth2SuccessMsg" class="card mb-4 border-left-success" style="display: none;">
+                    <div id="oauth2SuccessTxt" class="card-body">To start the OAuth2 flow and get a token follow this <a id="oauth2link" href="#" onclick="dismissOAuthModal();"  target="_blank">link</a>.</div>
+                </div>
+                <div id="oauth2ErrorMsg" class="card mb-4 border-left-warning" style="display: none;">
+                    <div id="oauth2ErrorTxt" class="card-body text-form-error"></div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
 {{end}}
 {{define "extra_js"}}
 <script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
@@ -351,6 +429,72 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         $('#spinnerModal').modal('show');
     }
 
+    function dismissOAuthModal(){
+        setTimeout(function () {
+            $('#smtpOAuthFlowModal').modal('hide');
+        }, 2000);
+    }
+
+    function getCurrentURI(){
+        let port = window.location.port;
+        if (port){
+            return window.location.protocol+"//"+window.location.hostname+":"+port;
+        }
+        return window.location.protocol+"//"+window.location.hostname;
+    }
+
+    function getRefreshToken(event){
+        event.preventDefault();
+        $('#oauth2SuccessMsg').hide();
+        $('#oauth2ErrorMsg').hide();
+        showSpinner();
+
+        let data = {"base_redirect_url": getCurrentURI(), "provider": parseInt($('#idSMTPOAuth2Provider').val()),
+            "tenant": $('#idSMTPOauth2Tenant').val(), "client_id": $('#idSMTPOauth2ClientID').val(),
+            "client_secret": $('#idSMTPOAuth2ClientSecret').val()};
+
+        $.ajax({
+            url: "{{.OAuth2TokenURL}}",
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify(data),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('#spinnerModal').modal('hide');
+                spinnerDone = true;
+                if (result && result.message){
+                    $('#oauth2link').attr("href", result.message);
+                    $('#oauth2SuccessMsg').show();
+                    $('#smtpOAuthFlowModal').modal('show');
+                } else {
+                    $('#oauth2ErrorTxt').text("Unable to get the URI to start OAuth2 flow");
+                    $('#oauth2ErrorMsg').show();
+                    $('#smtpOAuthFlowModal').modal('show');
+                }
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                $('#spinnerModal').modal('hide');
+                spinnerDone = true;
+                let txt = "Unable to get the URI to start OAuth2 flow";
+                if ($xhr) {
+                    let json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#oauth2ErrorTxt').text(txt);
+                $('#oauth2ErrorMsg').show();
+                $('#smtpOAuthFlowModal').modal('show');
+            }
+        });
+    }
+
     function testSMTP(event){
         event.preventDefault();
         let recipient = $('#idSMTPRecipient').val();
@@ -368,11 +512,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         $('#smtpErrorMsg').hide();
         showSpinner();
 
+        let data = {"host": $('#idSMTPHost').val(),"port": parseInt($('#idSMTPPort').val()),
+            "from": $('#idSMTPFrom').val(),"user": $('#idSMTPUsername').val(),"password": $('#idSMTPPassword').val(),
+            "auth_type": parseInt($('#idSMTPAuth').val()),"encryption": parseInt($('#idSMTPEncryption').val()),
+            "domain": $('#idSMTPDomain').val(),"debug": debug, "oauth2": {"provider": parseInt($('#idSMTPOAuth2Provider').val()),
+            "tenant": $('#idSMTPOauth2Tenant').val(), "client_id": $('#idSMTPOauth2ClientID').val(),
+            "client_secret": $('#idSMTPOAuth2ClientSecret').val(), "refresh_token": $('#idSMTPOAuth2RefreshToken').val()},
+            "recipient": recipient};
+
         $.ajax({
             url: "{{.ConfigsURL}}/smtp/test",
             type: 'POST',
             headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-            data: JSON.stringify({"host": $('#idSMTPHost').val(),"port": parseInt($('#idSMTPPort').val()),"from": $('#idSMTPFrom').val(),"user": $('#idSMTPUsername').val(),"password": $('#idSMTPPassword').val(),"auth_type": parseInt($('#idSMTPAuth').val()),"encryption": parseInt($('#idSMTPEncryption').val()), "domain": $('#idSMTPDomain').val(),"debug": debug, "recipient": recipient}),
+            data: JSON.stringify(data),
             dataType: 'json',
             contentType: 'application/json; charset=utf-8',
             timeout: 15000,
@@ -403,12 +555,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         });
     }
 
+    function onSMTPAuthChanged(val){
+        if (val == '3'){
+            $('.smtp-oauth2').show();
+            onSMTPOAuth2ProviderChanged($('#idSMTPOAuth2Provider').val());
+            return;
+        }
+        $('.smtp-oauth2').hide();
+    }
+
+    function onSMTPOAuth2ProviderChanged(val){
+        if (val == '1'){
+            $('.smtp-oauth2-microsoft').show();
+            return;
+        }
+        $('.smtp-oauth2-microsoft').hide();
+    }
+
     $(document).ready(function () {
         $('#spinnerModal').on('shown.bs.modal', function () {
             if (spinnerDone){
                 $('#spinnerModal').modal('hide');
             }
         });
+        onSMTPAuthChanged('{{.Configs.SMTP.AuthType}}');
+        onSMTPOAuth2ProviderChanged('{{.Configs.SMTP.OAuth2.Provider}}');
+        $('#smtpOauth2ProviderHelpBlock').text('The URI to redirect to after user authentication is '+getCurrentURI()+'{{.OAuth2RedirectURL}}');
     });
 </script>
 {{end}}