Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔒 Add SASL EXTERNAL mechanism #170

Merged
merged 1 commit into from
Sep 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,12 @@ def starttls(options = {}, verify = true)
# Allows the user to gain access to public services or resources without
# authenticating or disclosing an identity.
#
# +EXTERNAL+::
# See ExternalAuthenticator[Net::IMAP::SASL::ExternalAuthenticator].
#
# Authenticates using already established credentials, such as a TLS
# certificate or IPsec.
#
# +OAUTHBEARER+::
# See OAuthBearerAuthenticator[rdoc-ref:Net::IMAP::SASL::OAuthBearerAuthenticator].
#
Expand Down
7 changes: 7 additions & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ class IMAP
# Allows the user to gain access to public services or resources without
# authenticating or disclosing an identity.
#
# +EXTERNAL+::
# See ExternalAuthenticator[Net::IMAP::SASL::ExternalAuthenticator].
#
# Authenticates using already established credentials, such as a TLS
# certificate or IPsec.
#
# +OAUTHBEARER+::
# See OAuthBearerAuthenticator.
#
Expand Down Expand Up @@ -85,6 +91,7 @@ module SASL
autoload :GS2Header, "#{sasl_dir}/gs2_header"

autoload :AnonymousAuthenticator, "#{sasl_dir}/anonymous_authenticator"
autoload :ExternalAuthenticator, "#{sasl_dir}/external_authenticator"
autoload :OAuthBearerAuthenticator, "#{sasl_dir}/oauthbearer_authenticator"
autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator"
Expand Down
1 change: 1 addition & 0 deletions lib/net/imap/sasl/authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def initialize(use_defaults: false)
@authenticators = {}
if use_defaults
add_authenticator "Anonymous"
add_authenticator "External"
add_authenticator "OAuthBearer"
add_authenticator "Plain"
add_authenticator "XOAuth2"
Expand Down
53 changes: 53 additions & 0 deletions lib/net/imap/sasl/external_authenticator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

module Net
class IMAP < Protocol
module SASL

# Authenticator for the "+EXTERNAL+" SASL mechanism, as specified by
# RFC-4422[https://tools.ietf.org/html/rfc4422]. See
# Net::IMAP#authenticate.
#
# The EXTERNAL mechanism requests that the server use client credentials
# established external to SASL, for example by TLS certificate or IPsec.
class ExternalAuthenticator

# :call-seq:
# new(authzid: nil, **) -> authenticator
#
# Creates an Authenticator for the "+EXTERNAL+" SASL mechanism, as
# specified in RFC-4422[https://tools.ietf.org/html/rfc4422]. To use
# this, see Net::IMAP#authenticate or your client's authentication
# method.
#
# #authzid is an optional identity to act as or on behalf of.
#
# Any other keyword parameters are quietly ignored.
def initialize(authzid: nil)
@authzid = authzid&.to_str&.encode "UTF-8"
if @authzid&.match?(/\u0000/u) # also validates UTF8 encoding
raise ArgumentError, "contains NULL"
end
end

# Authorization identity: an identity to act as or on behalf of.
#
# If not explicitly provided, the server defaults to using the identity
# that was authenticated by the external credentials.
attr_reader :authzid

# :call-seq:
# initial_response? -> true
#
# +EXTERNAL+ can send an initial client response.
def initial_response?; true end

# Returns #authzid, or an empty string if there is no authzid.
def process(_)
authzid || ""
end

end
end
end
end
29 changes: 29 additions & 0 deletions test/net/imap/test_imap_authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,35 @@ def test_anonymous_length_over_255
assert_raise(ArgumentError) { anonymous("a" * 256).process(nil) }
end

# ----------------------
# EXTERNAL
# ----------------------

def external(...)
Net::IMAP::SASL.authenticator("EXTERNAL", ...)
end

def test_external_matches_mechanism
assert_kind_of(Net::IMAP::SASL::ExternalAuthenticator, external)
end

def test_external_response
assert_equal("", external.process(nil))
assert_equal("kwarg", external(authzid: "kwarg").process(nil))
end

def test_external_utf8
assert_equal("", external.process(nil))
assert_equal("🏴󠁧󠁢󠁥󠁮󠁧󠁿 England",
external(authzid: "🏴󠁧󠁢󠁥󠁮󠁧󠁿 England").process(nil))
end

def test_external_invalid
assert_raise(ArgumentError) { external(authzid: "bad\0contains NULL") }
assert_raise(ArgumentError) { external(authzid: "invalid utf8\x80") }
assert_raise(ArgumentError) { external("invalid positional argument") }
end

# ----------------------
# LOGIN (obsolete)
# ----------------------
Expand Down