From 3eb4301f81ad43baf3f0b16259e0deddf8328884 Mon Sep 17 00:00:00 2001 From: Jeremy Frey Date: Wed, 30 Jul 2025 16:17:22 -0400 Subject: [PATCH] Add support for XOAUTH2 and OAUTHBEARER auth mechanisms --- Src/SmtpServer.Tests/MailClient.cs | 22 +++ Src/SmtpServer.Tests/SmtpParserTests.cs | 64 +++++++ Src/SmtpServer.Tests/SmtpServer.Tests.csproj | 1 + Src/SmtpServer.Tests/SmtpServerTests.cs | 105 +++++++++++- .../BearerTokenAuthenticator.cs | 50 ++++++ .../DelegatingBearerTokenAuthenticator.cs | 77 +++++++++ ...legatingBearerTokenAuthenticatorFactory.cs | 32 ++++ .../IBearerTokenAuthenticator.cs | 21 +++ .../IBearerTokenAuthenticatorFactory.cs | 9 + .../ComponentModel/ServiceProvider.cs | 16 ++ Src/SmtpServer/Protocol/AuthCommand.cs | 160 ++++++++++++++++-- .../Protocol/AuthenticationMethod.cs | 12 +- Src/SmtpServer/Protocol/EhloCommand.cs | 28 ++- Src/SmtpServer/Protocol/SmtpParser.cs | 74 ++++++++ 14 files changed, 648 insertions(+), 23 deletions(-) create mode 100644 Src/SmtpServer/Authentication/BearerTokenAuthenticator.cs create mode 100644 Src/SmtpServer/Authentication/DelegatingBearerTokenAuthenticator.cs create mode 100644 Src/SmtpServer/Authentication/DelegatingBearerTokenAuthenticatorFactory.cs create mode 100644 Src/SmtpServer/Authentication/IBearerTokenAuthenticator.cs create mode 100644 Src/SmtpServer/Authentication/IBearerTokenAuthenticatorFactory.cs diff --git a/Src/SmtpServer.Tests/MailClient.cs b/Src/SmtpServer.Tests/MailClient.cs index 36aded4..b6cdb3f 100644 --- a/Src/SmtpServer.Tests/MailClient.cs +++ b/Src/SmtpServer.Tests/MailClient.cs @@ -81,6 +81,28 @@ public static void Send( client.Disconnect(true); } +#nullable enable + public static void Send( + SaslMechanism saslMechanism, + MimeMessage? message = null! + ) + { + message ??= Message(); + + System.ArgumentNullException.ThrowIfNull(message); + System.ArgumentNullException.ThrowIfNull(saslMechanism); + + using var client = Client(); + + client.Authenticate(saslMechanism); + + //client.NoOp(); + + client.Send(message); + client.Disconnect(true); + } +#nullable restore + public static void NoOp(SecureSocketOptions options = SecureSocketOptions.Auto) { using var client = Client(options: options); diff --git a/Src/SmtpServer.Tests/SmtpParserTests.cs b/Src/SmtpServer.Tests/SmtpParserTests.cs index 555093c..f7b0617 100644 --- a/Src/SmtpServer.Tests/SmtpParserTests.cs +++ b/Src/SmtpServer.Tests/SmtpParserTests.cs @@ -1,12 +1,17 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; using System.Net; +using System.Security.Claims; using System.Text; using SmtpServer.Mail; using SmtpServer.Protocol; using SmtpServer.Text; using Xunit; +using SecurityAlgorithms = Microsoft.IdentityModel.Tokens.SecurityAlgorithms; +using SigningCredentials = Microsoft.IdentityModel.Tokens.SigningCredentials; +using SymmetricSecurityKey = Microsoft.IdentityModel.Tokens.SymmetricSecurityKey; namespace SmtpServer.Tests { @@ -149,6 +154,44 @@ public void CanMakeAuthLogin() Assert.Equal("Y2Fpbi5vc3VsbGl2YW5AZ21haWwuY29t", ((AuthCommand)command).Parameter); } + [Fact] + public void CanMakeAuthXOAuth2() + { + // arrange + string email = "test-user@host.com"; + string token = GenerateJwt(email, "my-very-long-at-least-32-bytes-key"); + string authString = $"user={email}\u0001auth=Bearer {token}\u0001\u0001"; + + var reader = CreateReader($"AUTH XOAUTH2 {Convert.ToBase64String(Encoding.UTF8.GetBytes(authString))}"); + + // act + var result = Parser.TryMakeAuth(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is AuthCommand); + Assert.Equal(AuthenticationMethod.XOAuth2, ((AuthCommand) command).Method); + } + + [Fact] + public void CanMakeAuthOAuthBearer() + { + // arrange + string email = "test-user@host.com"; + string token = GenerateJwt(email, "my-very-long-at-least-32-bytes-key"); + string authString = $"user={email}\u0001auth=Bearer {token}\u0001\u0001"; + + var reader = CreateReader($"AUTH OAUTHBEARER {Convert.ToBase64String(Encoding.UTF8.GetBytes(authString))}"); + + // act + var result = Parser.TryMakeAuth(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is AuthCommand); + Assert.Equal(AuthenticationMethod.OAuthBearer, ((AuthCommand) command).Method); + } + [Theory] [InlineData("MAIL FROM:", "cain.osullivan", "gmail.com")] [InlineData(@"MAIL FROM:<""Abc@def""@example.com>", "Abc@def", "example.com")] @@ -688,5 +731,26 @@ public void CanNotMakeIPv6AddressLiteral(string input) // assert Assert.False(result); } + + static string GenerateJwt(string nameId, string key) + { + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var handler = new JwtSecurityTokenHandler(); + var token = handler.CreateJwtSecurityToken( + issuer: "test-issuer", + audience: "smtp", + subject: new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, nameId), + ]), + notBefore: DateTime.UtcNow, + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: credentials + ); + + return handler.WriteToken(token); + } } } diff --git a/Src/SmtpServer.Tests/SmtpServer.Tests.csproj b/Src/SmtpServer.Tests/SmtpServer.Tests.csproj index 9a47936..ec6b84c 100644 --- a/Src/SmtpServer.Tests/SmtpServer.Tests.csproj +++ b/Src/SmtpServer.Tests/SmtpServer.Tests.csproj @@ -7,6 +7,7 @@ + all diff --git a/Src/SmtpServer.Tests/SmtpServerTests.cs b/Src/SmtpServer.Tests/SmtpServerTests.cs index 300bf03..e8b4837 100644 --- a/Src/SmtpServer.Tests/SmtpServerTests.cs +++ b/Src/SmtpServer.Tests/SmtpServerTests.cs @@ -1,23 +1,27 @@ -using MailKit; -using SmtpServer.Authentication; -using SmtpServer.ComponentModel; -using SmtpServer.Mail; -using SmtpServer.Net; -using SmtpServer.Protocol; -using SmtpServer.Storage; -using SmtpServer.Tests.Mocks; -using System; +using System; using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; +using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading; using System.Threading.Tasks; +using MailKit; +using MailKit.Security; +using Microsoft.IdentityModel.Tokens; +using SmtpServer.Authentication; +using SmtpServer.ComponentModel; +using SmtpServer.Mail; +using SmtpServer.Net; +using SmtpServer.Protocol; +using SmtpServer.Storage; +using SmtpServer.Tests.Mocks; using Xunit; using SmtpResponse = SmtpServer.Protocol.SmtpResponse; @@ -95,6 +99,68 @@ public void CanAuthenticateUser() } } +#nullable enable + [Fact] + public void CanAuthenticateUser_WithXOAuth2() + { + // arrange + string? actualUser = null; + string? actualBearerToken = null; + + var bearerTokenAuthenticator = new DelegatingBearerTokenAuthenticator((u, bt) => + { + actualUser = u; + actualBearerToken = bt; + + return true; + }); + + string nameIdentifier = "user@host.com"; + string bearerToken = GenerateJwt(nameIdentifier, "my-very-long-at-least-32-bytes-key"); + + using (CreateServer(endpoint => endpoint.AllowUnsecureAuthentication(), services => services.Add(bearerTokenAuthenticator))) + { + // act + MailClient.Send(new SaslMechanismOAuth2(nameIdentifier, bearerToken)); + + // assert + Assert.Single(MessageStore.Messages); + Assert.Equal(nameIdentifier, actualUser); + Assert.Equal(bearerToken, actualBearerToken); + } + } + + [Fact] + public void CanAuthenticateUser_WithOAuthBearer() + { + // arrange + string? actualUser = null; + string? actualBearerToken = null; + + var bearerTokenAuthenticator = new DelegatingBearerTokenAuthenticator((u, bt) => + { + actualUser = u; + actualBearerToken = bt; + + return true; + }); + + string nameIdentifier = "user@host.com"; + string bearerToken = GenerateJwt(nameIdentifier, "my-very-long-at-least-32-bytes-key"); + + using (CreateServer(endpoint => endpoint.AllowUnsecureAuthentication(), services => services.Add(bearerTokenAuthenticator))) + { + // act + MailClient.Send(new SaslMechanismOAuthBearer(nameIdentifier, bearerToken)); + + // assert + Assert.Single(MessageStore.Messages); + Assert.Equal(nameIdentifier, actualUser); + Assert.Equal(bearerToken, actualBearerToken); + } + } +#nullable restore + [Theory] [InlineData("", "")] [InlineData("user", "")] @@ -639,5 +705,26 @@ SmtpServerDisposable CreateServer( /// The cancellation token source for the test. /// public CancellationTokenSource CancellationTokenSource { get; } + + static string GenerateJwt(string nameId, string key) + { + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var handler = new JwtSecurityTokenHandler(); + var token = handler.CreateJwtSecurityToken( + issuer: "test-issuer", + audience: "smtp", + subject: new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, nameId), + ]), + notBefore: DateTime.UtcNow, + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: credentials + ); + + return handler.WriteToken(token); + } } } diff --git a/Src/SmtpServer/Authentication/BearerTokenAuthenticator.cs b/Src/SmtpServer/Authentication/BearerTokenAuthenticator.cs new file mode 100644 index 0000000..5ad3c16 --- /dev/null +++ b/Src/SmtpServer/Authentication/BearerTokenAuthenticator.cs @@ -0,0 +1,50 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace SmtpServer.Authentication +{ + /// + /// Bearer Token Authenticator + /// + public abstract class BearerTokenAuthenticator : IBearerTokenAuthenticator + { + /// + /// Default Bearer Token Authenticator + /// + public static readonly IBearerTokenAuthenticator Default = new DefaultBearerTokenAuthenticator(); + + /// + /// Authenticate a user account utilizing a bearer token. + /// + /// The session context. + /// The user to authenticate. + /// The bearer token of the user. + /// The cancellation token. + /// true if the user is authenticated, false if not. + public abstract Task AuthenticateAsync( + ISessionContext context, + string user, + string bearerToken, + CancellationToken cancellationToken); + + sealed class DefaultBearerTokenAuthenticator : BearerTokenAuthenticator + { + /// + /// Authenticate a user account utilizing a bearer token. + /// + /// The session context. + /// The user to authenticate. + /// The bearer token of the user. + /// The cancellation token. + /// true if the user is authenticated, false if not. + public override Task AuthenticateAsync( + ISessionContext context, + string user, + string bearerToken, + CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + } + } +} diff --git a/Src/SmtpServer/Authentication/DelegatingBearerTokenAuthenticator.cs b/Src/SmtpServer/Authentication/DelegatingBearerTokenAuthenticator.cs new file mode 100644 index 0000000..16c0c54 --- /dev/null +++ b/Src/SmtpServer/Authentication/DelegatingBearerTokenAuthenticator.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace SmtpServer.Authentication +{ + /// + /// Delegating BearerToken Authenticator + /// + public sealed class DelegatingBearerTokenAuthenticator : BearerTokenAuthenticator + { + readonly Func _delegate; + + /// + /// Constructor. + /// + /// THe delegate to execute for the authentication. + public DelegatingBearerTokenAuthenticator(Action @delegate) : this(Wrap(@delegate)) { } + + /// + /// Constructor. + /// + /// THe delegate to execute for the authentication. + public DelegatingBearerTokenAuthenticator(Func @delegate) : this(Wrap(@delegate)) { } + + /// + /// Constructor. + /// + /// THe delegate to execute for the authentication. + public DelegatingBearerTokenAuthenticator(Func @delegate) + { + _delegate = @delegate; + } + + /// + /// Wrap the delegate into a function that is compatible with the signature. + /// + /// The delegate to wrap. + /// The function that is compatible with the main signature. + static Func Wrap(Func @delegate) + { + return (context, bearerToken, password) => @delegate(bearerToken, password); + } + + /// + /// Wrap the delegate into a function that is compatible with the signature. + /// + /// The delegate to wrap. + /// The function that is compatible with the main signature. + static Func Wrap(Action @delegate) + { + return (context, bearerToken, password) => + { + @delegate(bearerToken, password); + + return true; + }; + } + + /// + /// Authenticate a bearerToken account. + /// + /// The session context. + /// The bearerToken to authenticate. + /// The password of the bearerToken. + /// The cancellation token. + /// true if the bearerToken is authenticated, false if not. + public override Task AuthenticateAsync( + ISessionContext context, + string bearerToken, + string password, + CancellationToken cancellationToken) + { + return Task.FromResult(_delegate(context, bearerToken, password)); + } + } +} diff --git a/Src/SmtpServer/Authentication/DelegatingBearerTokenAuthenticatorFactory.cs b/Src/SmtpServer/Authentication/DelegatingBearerTokenAuthenticatorFactory.cs new file mode 100644 index 0000000..e44953c --- /dev/null +++ b/Src/SmtpServer/Authentication/DelegatingBearerTokenAuthenticatorFactory.cs @@ -0,0 +1,32 @@ +using System; + +namespace SmtpServer.Authentication +{ + /// + /// Delegating Bearer Token Authenticator Factory + /// + public class DelegatingBearerTokenAuthenticatorFactory : IBearerTokenAuthenticatorFactory + { + readonly Func _delegate; + + /// + /// Delegating Bearer Authenticator Factory + /// + /// + public DelegatingBearerTokenAuthenticatorFactory(Func @delegate) + { + _delegate = @delegate; + } + + /// + /// Creates an instance of the service for the given session context. + /// + /// The session context. + /// The service instance for the session context. + public IBearerTokenAuthenticator CreateInstance(ISessionContext context) + { + return _delegate(context); + } + + } +} diff --git a/Src/SmtpServer/Authentication/IBearerTokenAuthenticator.cs b/Src/SmtpServer/Authentication/IBearerTokenAuthenticator.cs new file mode 100644 index 0000000..7b46e08 --- /dev/null +++ b/Src/SmtpServer/Authentication/IBearerTokenAuthenticator.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace SmtpServer.Authentication +{ + /// + /// User Authenticator Interface + /// + public interface IBearerTokenAuthenticator + { + /// + /// Authenticate a user account. + /// + /// The session context. + /// The user to authenticate. + /// The bearer token. + /// The cancellation token. + /// true if the user is authenticated, false if not. + Task AuthenticateAsync(ISessionContext context, string user, string bearerToken, CancellationToken cancellationToken); + } +} diff --git a/Src/SmtpServer/Authentication/IBearerTokenAuthenticatorFactory.cs b/Src/SmtpServer/Authentication/IBearerTokenAuthenticatorFactory.cs new file mode 100644 index 0000000..963a468 --- /dev/null +++ b/Src/SmtpServer/Authentication/IBearerTokenAuthenticatorFactory.cs @@ -0,0 +1,9 @@ +using SmtpServer.ComponentModel; + +namespace SmtpServer.Authentication +{ + /// + /// Bearer Token Authenticator Factory Interface + /// + public interface IBearerTokenAuthenticatorFactory : ISessionContextInstanceFactory { } +} diff --git a/Src/SmtpServer/ComponentModel/ServiceProvider.cs b/Src/SmtpServer/ComponentModel/ServiceProvider.cs index 2230b53..b2c3ce5 100644 --- a/Src/SmtpServer/ComponentModel/ServiceProvider.cs +++ b/Src/SmtpServer/ComponentModel/ServiceProvider.cs @@ -18,6 +18,7 @@ public sealed class ServiceProvider : IServiceProvider IEndpointListenerFactory _endpointListenerFactory; IUserAuthenticatorFactory _userAuthenticatorFactory; + IBearerTokenAuthenticatorFactory _bearerTokenAuthenticatorFactory; ISmtpCommandFactory _smtpCommandFactory; IMailboxFilterFactory _mailboxFilterFactory; IMessageStoreFactory _messageStoreFactory; @@ -28,6 +29,7 @@ public sealed class ServiceProvider : IServiceProvider public ServiceProvider() { Add(UserAuthenticator.Default); + Add(BearerTokenAuthenticator.Default); Add(MailboxFilter.Default); Add(MessageStore.Default); } @@ -59,6 +61,15 @@ public void Add(IUserAuthenticator userAuthenticator) _userAuthenticatorFactory = new DelegatingUserAuthenticatorFactory(context => userAuthenticator); } + /// + /// Add an instance of the bearer token authenticator. + /// + /// The bearer token authenticator. + public void Add(IBearerTokenAuthenticator bearerTokenAuthenticator) + { + _bearerTokenAuthenticatorFactory = new DelegatingBearerTokenAuthenticatorFactory(context => bearerTokenAuthenticator); + } + /// /// Add an instance of the SMTP command factory. /// @@ -121,6 +132,11 @@ public object GetService(Type serviceType) return _userAuthenticatorFactory; } + if (serviceType == typeof(IBearerTokenAuthenticatorFactory)) + { + return _bearerTokenAuthenticatorFactory; + } + if (serviceType == typeof(ISmtpCommandFactory)) { return _smtpCommandFactory; diff --git a/Src/SmtpServer/Protocol/AuthCommand.cs b/Src/SmtpServer/Protocol/AuthCommand.cs index 6fb980f..31ddb2b 100644 --- a/Src/SmtpServer/Protocol/AuthCommand.cs +++ b/Src/SmtpServer/Protocol/AuthCommand.cs @@ -22,6 +22,9 @@ public sealed class AuthCommand : SmtpCommand string _user; string _password; +#nullable enable + string? _bearerToken; +#nullable restore /// /// Constructor. @@ -62,25 +65,65 @@ internal override async Task ExecuteAsync(SmtpSessionContext context, Canc return false; } break; + case AuthenticationMethod.XOAuth2: + if (await TryXOAuth2Async(context, cancellationToken).ConfigureAwait(false) == false) + { + await context.Pipe.Output.WriteReplyAsync(SmtpResponse.AuthenticationFailed, cancellationToken).ConfigureAwait(false); + return false; + } + break; + case AuthenticationMethod.OAuthBearer: + if (await TryOAuthBearerAsync(context, cancellationToken).ConfigureAwait(false) == false) + { + await context.Pipe.Output.WriteReplyAsync(SmtpResponse.AuthenticationFailed, cancellationToken).ConfigureAwait(false); + return false; + } + break; } - var userAuthenticator = context.ServiceProvider.GetService(context, UserAuthenticator.Default); - - using (var container = new DisposableContainer(userAuthenticator)) + if (Method == AuthenticationMethod.Plain || Method == AuthenticationMethod.Login) { - if (await container.Instance.AuthenticateAsync(context, _user, _password, cancellationToken).ConfigureAwait(false) == false) + var userAuthenticator = context.ServiceProvider.GetService(context, UserAuthenticator.Default); + + using (var container = new DisposableContainer(userAuthenticator)) { - var remaining = context.ServerOptions.MaxAuthenticationAttempts - ++context.AuthenticationAttempts; - var response = new SmtpResponse(SmtpReplyCode.AuthenticationFailed, $"authentication failed, {remaining} attempt(s) remaining."); + if (await container.Instance.AuthenticateAsync(context, _user, _password, cancellationToken).ConfigureAwait(false) == false) + { + var remaining = context.ServerOptions.MaxAuthenticationAttempts - ++context.AuthenticationAttempts; + var response = new SmtpResponse(SmtpReplyCode.AuthenticationFailed, $"authentication failed, {remaining} attempt(s) remaining."); - await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); - if (remaining <= 0) - { - throw new SmtpResponseException(SmtpResponse.ServiceClosingTransmissionChannel, true); + if (remaining <= 0) + { + throw new SmtpResponseException(SmtpResponse.ServiceClosingTransmissionChannel, true); + } + + return false; } + } + } - return false; + if (Method == AuthenticationMethod.XOAuth2 || Method == AuthenticationMethod.OAuthBearer) + { + var bearerTokenAuthenticator = context.ServiceProvider.GetService(context, BearerTokenAuthenticator.Default); + + using (var container = new DisposableContainer(bearerTokenAuthenticator)) + { + if (await container.Instance.AuthenticateAsync(context, _user, _bearerToken, cancellationToken).ConfigureAwait(false) == false) + { + var remaining = context.ServerOptions.MaxAuthenticationAttempts - ++context.AuthenticationAttempts; + var response = new SmtpResponse(SmtpReplyCode.AuthenticationFailed, $"authentication failed, {remaining} attempt(s) remaining."); + + await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + + if (remaining <= 0) + { + throw new SmtpResponseException(SmtpResponse.ServiceClosingTransmissionChannel, true); + } + + return false; + } } } @@ -166,6 +209,101 @@ async Task TryLoginAsync(ISessionContext context, CancellationToken cancel return true; } +#nullable enable + /// + /// Attempt an XOAUTH2 login sequence. + /// + /// The execution context to operate on. + /// The cancellation token. + /// true if the LOGIN login sequence worked, false if not. + async Task TryXOAuth2Async(ISessionContext context, CancellationToken cancellationToken) + { + string? oauth2Response; + + if (string.IsNullOrWhiteSpace(Parameter) == false) + { + // inline + oauth2Response = Encoding.UTF8.GetString(Convert.FromBase64String(Parameter)); + } + else + { + // interactive + await context.Pipe.Output.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.ContinueWithAuth, ""), cancellationToken); + + oauth2Response = await ReadBase64EncodedLineAsync(context.Pipe.Input, cancellationToken); + } + + if (oauth2Response != null) + { + string[] oauth2Parameters = oauth2Response.Split("\u0001", StringSplitOptions.RemoveEmptyEntries); + + if (oauth2Parameters.Length > 0) + { + string usernameComponent = oauth2Parameters[0]; + if (usernameComponent.StartsWith("user=", StringComparison.OrdinalIgnoreCase)) + { + _user = usernameComponent[5..]; + } + + string bearerTokenComponent = oauth2Parameters[1]; + if (bearerTokenComponent.StartsWith("auth=Bearer ", StringComparison.OrdinalIgnoreCase)) + { + _bearerToken = bearerTokenComponent[12..]; + } + } + } + + return _user != null && _bearerToken != null; + } + + /// + /// Attempt an OAUTHBEARER login sequence. + /// + /// The execution context to operate on. + /// The cancellation token. + /// true if the LOGIN login sequence worked, false if not. + async Task TryOAuthBearerAsync(ISessionContext context, CancellationToken cancellationToken) + { + string? oauth2Response; + + if (string.IsNullOrWhiteSpace(Parameter) == false) + { + // inline + oauth2Response = Encoding.UTF8.GetString(Convert.FromBase64String(Parameter)); + } + else + { + // interactive + await context.Pipe.Output.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.ContinueWithAuth, ""), cancellationToken); + + oauth2Response = await ReadBase64EncodedLineAsync(context.Pipe.Input, cancellationToken); + } + + if (oauth2Response != null) + { + string[] fields = oauth2Response.Split("\u0001", StringSplitOptions.RemoveEmptyEntries); + + foreach (string field in fields) + { + if (field.StartsWith("n,a=", StringComparison.OrdinalIgnoreCase)) + { + string identity = field[4..].TrimEnd(','); + if (!string.IsNullOrWhiteSpace(identity)) + { + _user = identity; + } + } + else if (field.StartsWith("auth=Bearer ", StringComparison.OrdinalIgnoreCase)) + { + _bearerToken = field[12..]; + } + } + } + + return _user != null && _bearerToken != null; + } +#nullable restore + /// /// Read a Base64 encoded line. /// diff --git a/Src/SmtpServer/Protocol/AuthenticationMethod.cs b/Src/SmtpServer/Protocol/AuthenticationMethod.cs index bb528a7..9e9fc73 100644 --- a/Src/SmtpServer/Protocol/AuthenticationMethod.cs +++ b/Src/SmtpServer/Protocol/AuthenticationMethod.cs @@ -13,6 +13,16 @@ public enum AuthenticationMethod /// /// Plain /// - Plain + Plain, + + /// + /// XOAuth2 + /// + XOAuth2, + + /// + /// OAuthBearer + /// + OAuthBearer } } diff --git a/Src/SmtpServer/Protocol/EhloCommand.cs b/Src/SmtpServer/Protocol/EhloCommand.cs index a9a483e..8745df9 100644 --- a/Src/SmtpServer/Protocol/EhloCommand.cs +++ b/Src/SmtpServer/Protocol/EhloCommand.cs @@ -80,9 +80,22 @@ protected virtual IEnumerable GetExtensions(ISessionContext context) yield return $"SIZE {context.ServerOptions.MaxMessageSize}"; } + var supportedLoginTypes = new List(); if (IsPlainLoginAllowed(context)) { - yield return "AUTH PLAIN LOGIN"; + supportedLoginTypes.Add("PLAIN"); + supportedLoginTypes.Add("LOGIN"); + } + + if (IsBearerTokenLoginAllowed(context)) + { + supportedLoginTypes.Add("XOAUTH2"); + supportedLoginTypes.Add("OAUTHBEARER"); + } + + if (supportedLoginTypes.Count > 0) + { + yield return $"AUTH {string.Join(" ", supportedLoginTypes)}"; } static bool IsPlainLoginAllowed(ISessionContext context) @@ -94,8 +107,19 @@ static bool IsPlainLoginAllowed(ISessionContext context) return context.Pipe.IsSecure || context.EndpointDefinition.AllowUnsecureAuthentication; } + + static bool IsBearerTokenLoginAllowed(ISessionContext context) + { + if (context.ServiceProvider.GetService(typeof(IBearerTokenAuthenticatorFactory)) == null + && context.ServiceProvider.GetService(typeof(IBearerTokenAuthenticator)) == null) + { + return false; + } + + return context.Pipe.IsSecure || context.EndpointDefinition.AllowUnsecureAuthentication; + } } - + /// /// Gets the domain name or address literal. /// diff --git a/Src/SmtpServer/Protocol/SmtpParser.cs b/Src/SmtpServer/Protocol/SmtpParser.cs index 38018cb..797dd80 100644 --- a/Src/SmtpServer/Protocol/SmtpParser.cs +++ b/Src/SmtpServer/Protocol/SmtpParser.cs @@ -671,6 +671,18 @@ public bool TryMakeAuthenticationMethod(ref TokenReader reader, out Authenticati return true; } + if (reader.TryMake(TryMakeXOAuth2Literal)) + { + authenticationMethod = AuthenticationMethod.XOAuth2; + return true; + } + + if (reader.TryMake(TryMakeOAuthBearerLiteral)) + { + authenticationMethod = AuthenticationMethod.OAuthBearer; + return true; + } + authenticationMethod = default; return false; } @@ -740,6 +752,68 @@ public bool TryMakePlainLiteral(ref TokenReader reader) return false; } + /// + /// Try to make the XOAUTH2 text sequence. + /// + /// The reader to perform the operation on. + /// true if the XOAUTH2 text sequence could be made, false if not. + public bool TryMakeXOAuth2Literal(ref TokenReader reader) + { + bool isXOAuth = false; + bool isVersion2 = false; + + if (reader.TryMake(TryMakeText, out var text)) + { + Span command = stackalloc char[6]; + command[0] = 'X'; + command[1] = 'O'; + command[2] = 'A'; + command[3] = 'U'; + command[4] = 'T'; + command[5] = 'H'; + + isXOAuth = text.CaseInsensitiveStringEquals(ref command); + } + + if (isXOAuth && reader.TryMake(TryMakeNumber, out var number)) + { + Span command = stackalloc char[1]; + command[0] = '2'; + + isVersion2 = number.CaseInsensitiveStringEquals(ref command); + } + + return isXOAuth && isVersion2; + } + + /// + /// Try to make the OAUTHBEARER text sequence. + /// + /// The reader to perform the operation on. + /// true if the XOAUTH2 text sequence could be made, false if not. + public bool TryMakeOAuthBearerLiteral(ref TokenReader reader) + { + if (reader.TryMake(TryMakeText, out var text)) + { + Span command = stackalloc char[11]; + command[0] = 'O'; + command[1] = 'A'; + command[2] = 'U'; + command[3] = 'T'; + command[4] = 'H'; + command[5] = 'B'; + command[6] = 'E'; + command[7] = 'A'; + command[8] = 'R'; + command[9] = 'E'; + command[10] = 'R'; + + return text.CaseInsensitiveStringEquals(ref command); + } + + return false; + } + /// /// Support proxy protocol version 1 header for use with HAProxy. /// Documented at http://www.haproxy.org/download/1.8/doc/proxy-protocol.txt