Repository: oauth2-proxy/oauth2-proxy
Pull request #1286 (2021-07-28 08:30:42)

Add allowed_email_domains on auth_request endpoint
## Description

Add the allowed_email_domains on the auth_request endpoint to restrict access per ingress.

## Motivation and Context

Some users may want to have one oauth2 proxy with multiple allowed email domains but restrict access to some ressource to only one or multiple of these emails domains

## How Has This Been Tested?

Unit Tests and test on Kubernees with an Nginx ingress.

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My change requires a change to the documentation or CHANGELOG.
- [x] I have updated the documentation/CHANGELOG accordingly. 
- [x] I have created a feature (non-master) branch for my PR.


w3st3ry: Hi @NickMeves, WDYT about the change? Should I add something else?
NickMeves: > @NickMeves For the group authentication, didn't we have some requirement about having to have the allowed_group be defined on the config first to be allowed to be added in the query params?

@JoelSpeed I don't recall this being a requirement -- The architecture I built this for was a central Authentication Proxy (BeyondCorp style) where all the consuming applications (nginx, k8s ingress) could specificy any groups they cared about for AuthZ
github-actions[bot]: This pull request has been inactive for 60 days. If the pull request is still relevant please comment to re-activate the pull request. If no action is taken within 7 days, the pull request will be marked closed.
JoelSpeed: Apologies for letting this get stale, are you able to resolve the conflicts and we can get this merged?
JoelSpeed: @armandpicard GitHub seems to think there are conflicts on this branch, do you think you could try rebasing it?

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 085616e1..5f66a961 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
 - [#1509](https://github.com/oauth2-proxy/oauth2-proxy/pull/1509) Update LoginGovProvider ValidateSession to pass access_token in Header (@pksheldon4)
 - [#1474](https://github.com/oauth2-proxy/oauth2-proxy/pull/1474) Support configuration of minimal acceptable TLS version (@polarctos)
 - [#1545](https://github.com/oauth2-proxy/oauth2-proxy/pull/1545) Fix issue with query string allowed group panic on skip methods (@andytson)
+- [#1286](https://github.com/oauth2-proxy/oauth2-proxy/pull/1286) Add the `allowed_email_domains` and the `allowed_groups` on the `auth_request` + support standard wildcard char for validation with sub-domain and email-domain. (@w3st3ry @armandpicard)
 
 # V7.2.1
 
diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md
index 1b440114..89c472d7 100644
--- a/docs/docs/configuration/overview.md
+++ b/docs/docs/configuration/overview.md
@@ -197,12 +197,12 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--allowed-role` | string \| list | restrict logins to users with this role (may be given multiple times). Only works with the keycloak-oidc provider. | |
 | `--validate-url` | string | Access token validation endpoint | |
 | `--version` | n/a | print version string | |
-| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`)&nbsp;\[[2](#footnote2)\] | |
+| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` or a `*.` to allow subdomains (e.g. `.example.com`, `*.example.com`)&nbsp;\[[2](#footnote2)\] | |
 | `--trusted-ip` | string \| list | list of IPs or CIDR ranges to allow to bypass authentication (may be given multiple times). When combined with `--reverse-proxy` and optionally `--real-client-ip-header` this will evaluate the trust of the IP stored in an HTTP header by a reverse proxy rather than the layer-3/4 remote address. WARNING: trusting IPs has inherent security flaws, especially when obtaining the IP address from an HTTP header (reverse-proxy mode). Use this option only if you understand the risks and how to manage them. | |
 
 \[<a name="footnote1">1</a>\]: Only these providers support `--cookie-refresh`: GitLab, Google and OIDC
 
-\[<a name="footnote2">2</a>\]: When using the `whitelist-domain` option, any domain prefixed with a `.` will allow any subdomain of the specified domain as a valid redirect URL. By default, only empty ports are allowed. This translates to allowing the default port of the URL's protocol (80 for HTTP, 443 for HTTPS, etc.) since browsers omit them. To allow only a specific port, add it to the whitelisted domain: `example.com:8080`. To allow any port, use `*`: `example.com:*`.
+\[<a name="footnote2">2</a>\]: When using the `whitelist-domain` option, any domain prefixed with a `.` or a `*.` will allow any subdomain of the specified domain as a valid redirect URL. By default, only empty ports are allowed. This translates to allowing the default port of the URL's protocol (80 for HTTP, 443 for HTTPS, etc.) since browsers omit them. To allow only a specific port, add it to the whitelisted domain: `example.com:8080`. To allow any port, use `*`: `example.com:*`.
 
 See below for provider specific options
 
diff --git a/docs/docs/features/endpoints.md b/docs/docs/features/endpoints.md
index a4a89940..4832a5de 100644
--- a/docs/docs/features/endpoints.md
+++ b/docs/docs/features/endpoints.md
@@ -34,3 +34,11 @@ X-Auth-Request-Redirect: https://my-oidc-provider/sign_out_page
 (The "sign_out_page" should be the [`end_session_endpoint`](https://openid.net/specs/openid-connect-session-1_0.html#rfc.section.2.1) from [the metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig) if your OIDC provider supports Session Management and Discovery.)
 
 BEWARE that the domain you want to redirect to (`my-oidc-provider.example.com` in the example) must be added to the [`--whitelist-domain`](../configuration/overview) configuration option otherwise the redirect will be ignored.
+
+### Auth
+
+This endpoint returns 202 Accepted response or a 401 Unauthorized response.
+
+It can be configured using the following query parameters query parameters:
+- `allowed_groups`: comma separated list of allowed groups
+- `allowed_email_domains`: comma separated list of allowed email domains
\ No newline at end of file
diff --git a/oauthproxy.go b/oauthproxy.go
index 1cd35e65..702c6a39 100644
--- a/oauthproxy.go
+++ b/oauthproxy.go
@@ -26,6 +26,7 @@ import (
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/authentication/basic"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies"
 	proxyhttp "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/http"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
@@ -971,28 +972,72 @@ func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.R
 
 // authOnlyAuthorize handles special authorization logic that is only done
 // on the AuthOnly endpoint for use with Nginx subrequest architectures.
-//
-// TODO (@NickMeves): This method is a placeholder to be extended but currently
-// fails the linter. Remove the nolint when functionality expands.
-//
-//nolint:gosimple
 func authOnlyAuthorize(req *http.Request, s *sessionsapi.SessionState) bool {
 	// Allow requests previously allowed to be bypassed
 	if s == nil {
 		return true
 	}
 
-	// Allow secondary group restrictions based on the `allowed_groups`
-	// querystring parameter
-	if !checkAllowedGroups(req, s) {
-		return false
+	constraints := []func(*http.Request, *sessionsapi.SessionState) bool{
+		checkAllowedGroups,
+		checkAllowedEmailDomains,
+	}
+
+	for _, constraint := range constraints {
+		if !constraint(req, s) {
+			return false
+		}
 	}
 
 	return true
 }
 
+// extractAllowedEntities aims to extract and split allowed entities linked by a key,
+// from an HTTP request query. Output is a map[string]struct{} where keys are valuable,
+// the goal is to avoid time complexity O(N^2) while finding matches during membership checks.
+func extractAllowedEntities(req *http.Request, key string) map[string]struct{} {
+	entities := map[string]struct{}{}
+
+	query := req.URL.Query()
+	for _, allowedEntities := range query[key] {
+		for _, entity := range strings.Split(allowedEntities, ",") {
+			if entity != "" {
+				entities[entity] = struct{}{}
+			}
+		}
+	}
+
+	return entities
+}
+
+// checkAllowedEmailDomains allow email domain restrictions based on the `allowed_email_domains`
+// querystring parameter
+func checkAllowedEmailDomains(req *http.Request, s *sessionsapi.SessionState) bool {
+	allowedEmailDomains := extractAllowedEntities(req, "allowed_email_domains")
+	if len(allowedEmailDomains) == 0 {
+		return true
+	}
+
+	splitEmail := strings.Split(s.Email, "@")
+	if len(splitEmail) != 2 {
+		return false
+	}
+
+	endpoint, _ := url.Parse("")
+	endpoint.Host = splitEmail[1]
+
+	allowedEmailDomainsList := []string{}
+	for ed := range allowedEmailDomains {
+		allowedEmailDomainsList = append(allowedEmailDomainsList, ed)
+	}
+
+	return util.IsEndpointAllowed(endpoint, allowedEmailDomainsList)
+}
+
+// checkAllowedGroups allow secondary group restrictions based on the `allowed_groups`
+// querystring parameter
 func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) bool {
-	allowedGroups := extractAllowedGroups(req)
+	allowedGroups := extractAllowedEntities(req, "allowed_groups")
 	if len(allowedGroups) == 0 {
 		return true
 	}
@@ -1006,21 +1051,6 @@ func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) bool {
 	return false
 }
 
-func extractAllowedGroups(req *http.Request) map[string]struct{} {
-	groups := map[string]struct{}{}
-
-	query := req.URL.Query()
-	for _, allowedGroups := range query["allowed_groups"] {
-		for _, group := range strings.Split(allowedGroups, ",") {
-			if group != "" {
-				groups[group] = struct{}{}
-			}
-		}
-	}
-
-	return groups
-}
-
 // encodedState builds the OAuth state param out of our nonce and
 // original application redirect
 func encodeState(nonce string, redirect string) string {
diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go
index 26f2fb7b..bce12658 100644
--- a/pkg/apis/options/options.go
+++ b/pkg/apis/options/options.go
@@ -126,7 +126,7 @@ func NewFlagSet() *pflag.FlagSet {
 	flagSet.StringSlice("extra-jwt-issuers", []string{}, "if skip-jwt-bearer-tokens is set, a list of extra JWT issuer=audience pairs (where the issuer URL has a .well-known/openid-configuration or a .well-known/jwks.json)")
 
 	flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
-	flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)")
+	flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . or a *. to allow subdomains (eg .example.com, *.example.com)")
 	flagSet.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)")
 	flagSet.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -B\" for bcrypt encryption")
 	flagSet.StringSlice("htpasswd-user-group", []string{}, "the groups to be set on sessions for htpasswd users (may be given multiple times)")
diff --git a/pkg/app/redirect/validator.go b/pkg/app/redirect/validator.go
index 78834425..fd9074e2 100644
--- a/pkg/app/redirect/validator.go
+++ b/pkg/app/redirect/validator.go
@@ -6,6 +6,8 @@ import (
 	"strings"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
+
+	util "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
 )
 
 var (
@@ -50,28 +52,9 @@ func (v *validator) IsValidRedirect(redirect string) bool {
 			logger.Printf("Rejecting invalid redirect %q: scheme unsupported or missing", redirect)
 			return false
 		}
-		redirectHostname := redirectURL.Hostname()
-
-		for _, allowedDomain := range v.allowedDomains {
-			allowedHost, allowedPort := splitHostPort(allowedDomain)
-			if allowedHost == "" {
-				continue
-			}
 
-			if redirectHostname == strings.TrimPrefix(allowedHost, ".") ||
-				(strings.HasPrefix(allowedHost, ".") &&
-					strings.HasSuffix(redirectHostname, allowedHost)) {
-				// the domain names match, now validate the ports
-				// if the whitelisted domain's port is '*', allow all ports
-				// if the whitelisted domain contains a specific port, only allow that port
-				// if the whitelisted domain doesn't contain a port at all, only allow empty redirect ports ie http and https
-				redirectPort := redirectURL.Port()
-				if allowedPort == "*" ||
-					allowedPort == redirectPort ||
-					(allowedPort == "" && redirectPort == "") {
-					return true
-				}
-			}
+		if util.IsEndpointAllowed(redirectURL, v.allowedDomains) {
+			return true
 		}
 
 		logger.Printf("Rejecting invalid redirect %q: domain / port not in whitelist", redirect)
@@ -81,40 +64,3 @@ func (v *validator) IsValidRedirect(redirect string) bool {
 		return false
 	}
 }
-
-// splitHostPort separates host and port. If the port is not valid, it returns
-// the entire input as host, and it doesn't check the validity of the host.
-// Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
-// *** taken from net/url, modified validOptionalPort() to accept ":*"
-func splitHostPort(hostport string) (host, port string) {
-	host = hostport
-
-	colon := strings.LastIndexByte(host, ':')
-	if colon != -1 && validOptionalPort(host[colon:]) {
-		host, port = host[:colon], host[colon+1:]
-	}
-
-	if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
-		host = host[1 : len(host)-1]
-	}
-
-	return
-}
-
-// validOptionalPort reports whether port is either an empty string
-// or matches /^:\d*$/
-// *** taken from net/url, modified to accept ":*"
-func validOptionalPort(port string) bool {
-	if port == "" || port == ":*" {
-		return true
-	}
-	if port[0] != ':' {
-		return false
-	}
-	for _, b := range port[1:] {
-		if b < '0' || b > '9' {
-			return false
-		}
-	}
-	return true
-}
diff --git a/pkg/util/util.go b/pkg/util/util.go
index d7a7f82c..41c9a09f 100644
--- a/pkg/util/util.go
+++ b/pkg/util/util.go
@@ -9,6 +9,8 @@ import (
 	"io/ioutil"
 	"math/big"
 	"net"
+	"net/url"
+	"strings"
 	"time"
 )
 
@@ -66,3 +68,84 @@ func GenerateCert() ([]byte, []byte, error) {
 	certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
 	return certBytes, keyBytes, err
 }
+
+// SplitHostPort separates host and port. If the port is not valid, it returns
+// the entire input as host, and it doesn't check the validity of the host.
+// Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
+// *** taken from net/url, modified validOptionalPort() to accept ":*"
+func SplitHostPort(hostport string) (host, port string) {
+	host = hostport
+
+	colon := strings.LastIndexByte(host, ':')
+	if colon != -1 && validOptionalPort(host[colon:]) {
+		host, port = host[:colon], host[colon+1:]
+	}
+
+	if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
+		host = host[1 : len(host)-1]
+	}
+
+	return
+}
+
+// validOptionalPort reports whether port is either an empty string
+// or matches /^:\d*$/
+// *** taken from net/url, modified to accept ":*"
+func validOptionalPort(port string) bool {
+	if port == "" || port == ":*" {
+		return true
+	}
+	if port[0] != ':' {
+		return false
+	}
+	for _, b := range port[1:] {
+		if b < '0' || b > '9' {
+			return false
+		}
+	}
+	return true
+}
+
+// IsEndpointAllowed checks whether the endpoint URL is allowed based
+// on an allowed domains list.
+func IsEndpointAllowed(endpoint *url.URL, allowedDomains []string) bool {
+	hostname := endpoint.Hostname()
+
+	for _, allowedDomain := range allowedDomains {
+		allowedHost, allowedPort := SplitHostPort(allowedDomain)
+		if allowedHost == "" {
+			continue
+		}
+
+		if isHostnameAllowed(hostname, allowedHost) {
+			// the domain names match, now validate the ports
+			// if the allowed domain's port is '*', allow all ports
+			// if the allowed domain contains a specific port, only allow that port
+			// if the allowed domain doesn't contain a port at all, only allow empty redirect ports ie http and https
+			redirectPort := endpoint.Port()
+			if allowedPort == "*" ||
+				allowedPort == redirectPort ||
+				(allowedPort == "" && redirectPort == "") {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+func isHostnameAllowed(hostname, allowedHost string) bool {
+	// check if we have a perfect match between hostname and allowedHost
+	if hostname == strings.TrimPrefix(allowedHost, ".") ||
+		hostname == strings.TrimPrefix(allowedHost, "*.") {
+		return true
+	}
+
+	// check if hostname is a sub domain of the allowedHost
+	if (strings.HasPrefix(allowedHost, ".") && strings.HasSuffix(hostname, allowedHost)) ||
+		(strings.HasPrefix(allowedHost, "*.") && strings.HasSuffix(hostname, allowedHost[1:])) {
+		return true
+	}
+
+	return false
+}
diff --git a/validator.go b/validator.go
index 6d2a9b68..7f5349a8 100644
--- a/validator.go
+++ b/validator.go
@@ -115,12 +115,15 @@ func isEmailValidWithDomains(email string, allowedDomains []string) bool {
 			return true
 		}
 
-		// allow if the domain is prefixed with . and
+		// allow if the domain is prefixed with . or *. and
 		// the last element (split on @) has the suffix as the domain
 		atoms := strings.Split(email, "@")
-		if strings.HasPrefix(domain, ".") && strings.HasSuffix(atoms[len(atoms)-1], domain) {
+
+		if (strings.HasPrefix(domain, ".") && strings.HasSuffix(atoms[len(atoms)-1], domain)) ||
+			(strings.HasPrefix(domain, "*.") && strings.HasSuffix(atoms[len(atoms)-1], domain[1:])) {
 			return true
 		}
 	}
+
 	return false
 }


Test patch:
diff --git a/oauthproxy_test.go b/oauthproxy_test.go
index 14f1a022..6241e1bb 100644
--- a/oauthproxy_test.go
+++ b/oauthproxy_test.go
@@ -2683,3 +2683,94 @@ func TestAuthOnlyAllowedGroupsWithSkipMethods(t *testing.T) {
 		})
 	}
 }
+
+func TestAuthOnlyAllowedEmailDomains(t *testing.T) {
+	testCases := []struct {
+		name               string
+		email              string
+		querystring        string
+		expectedStatusCode int
+	}{
+		{
+			name:               "NotEmailRestriction",
+			email:              "toto@example.com",
+			querystring:        "",
+			expectedStatusCode: http.StatusAccepted,
+		},
+		{
+			name:               "UserInAllowedEmailDomain",
+			email:              "toto@example.com",
+			querystring:        "?allowed_email_domains=example.com",
+			expectedStatusCode: http.StatusAccepted,
+		},
+		{
+			name:               "UserNotInAllowedEmailDomain",
+			email:              "toto@example.com",
+			querystring:        "?allowed_email_domains=a.example.com",
+			expectedStatusCode: http.StatusForbidden,
+		},
+		{
+			name:               "UserInAllowedEmailDomains",
+			email:              "toto@example.com",
+			querystring:        "?allowed_email_domains=a.example.com,b.example.com",
+			expectedStatusCode: http.StatusForbidden,
+		},
+		{
+			name:               "UserInAllowedEmailDomains",
+			email:              "toto@example.com",
+			querystring:        "?allowed_email_domains=a.example.com,example.com",
+			expectedStatusCode: http.StatusAccepted,
+		},
+		{
+			name:               "UserInAllowedEmailDomainWildcard",
+			email:              "toto@foo.example.com",
+			querystring:        "?allowed_email_domains=*.example.com",
+			expectedStatusCode: http.StatusAccepted,
+		},
+		{
+			name:               "UserNotInAllowedEmailDomainWildcard",
+			email:              "toto@example.com",
+			querystring:        "?allowed_email_domains=*.a.example.com",
+			expectedStatusCode: http.StatusForbidden,
+		},
+		{
+			name:               "UserInAllowedEmailDomainsWildcard",
+			email:              "toto@example.com",
+			querystring:        "?allowed_email_domains=*.a.example.com,*.b.example.com",
+			expectedStatusCode: http.StatusForbidden,
+		},
+		{
+			name:               "UserInAllowedEmailDomainsWildcard",
+			email:              "toto@c.example.com",
+			querystring:        "?allowed_email_domains=a.b.c.example.com,*.c.example.com",
+			expectedStatusCode: http.StatusAccepted,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			groups := []string{}
+
+			created := time.Now()
+
+			session := &sessions.SessionState{
+				Groups:      groups,
+				Email:       tc.email,
+				AccessToken: "oauth_token",
+				CreatedAt:   &created,
+			}
+
+			test, err := NewAuthOnlyEndpointTest(tc.querystring, func(opts *options.Options) {})
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			err = test.SaveSession(session)
+			assert.NoError(t, err)
+
+			test.proxy.ServeHTTP(test.rw, test.req)
+
+			assert.Equal(t, tc.expectedStatusCode, test.rw.Code)
+		})
+	}
+}
diff --git a/pkg/app/redirect/validator_test.go b/pkg/app/redirect/validator_test.go
index f7692bff..034a802c 100644
--- a/pkg/app/redirect/validator_test.go
+++ b/pkg/app/redirect/validator_test.go
@@ -5,6 +5,7 @@ import (
 	"net/url"
 	"os"
 
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
@@ -22,6 +23,10 @@ var _ = Describe("Validator suite", func() {
 			"anyport.bar:*",
 			".sub.anyport.bar:*",
 			"www.whitelisteddomain.tld",
+			"*.wildcard.sub.port.bar:8080",
+			"*.wildcard.sub.anyport.bar:*",
+			"*.wildcard.bar",
+			"*.wildcard.proxy.foo.bar",
 		}
 	})
 
@@ -96,7 +101,20 @@ var _ = Describe("Validator suite", func() {
 			Entry("Quad Tab 2", "/\t\t\\\t\t/evil.com", false),
 			Entry("Relative Path", "/./\\evil.com", false),
 			Entry("Relative Subpath", "/./../../\\evil.com", false),
-			Entry("Partial Subdomain", "evilbar.foo", false),
+			Entry("Valid HTTP Wildcard Subdomain", "http://foo.wildcard.bar/redirect", true),
+			Entry("Valid HTTPS Wildcard Subdomain", "https://foo.wildcard.bar/redirect", true),
+			Entry("Valid HTTP Wildcard Subdomain Root", "http://wildcard.bar/redirect", true),
+			Entry("Valid HTTPS Wildcard Subdomain Root", "https://wildcard.bar/redirect", true),
+			Entry("Valid HTTP Wildcard Subdomain anyport", "http://foo.wildcard.sub.anyport.bar:4242/redirect", true),
+			Entry("Valid HTTPS Wildcard Subdomain anyport", "https://foo.wildcard.sub.anyport.bar:4242/redirect", true),
+			Entry("Valid HTTP Wildcard Subdomain Anyport Root", "http://wildcard.sub.anyport.bar:4242/redirect", true),
+			Entry("Valid HTTPS Wildcard Subdomain Anyport Root", "https://wildcard.sub.anyport.bar:4242/redirect", true),
+			Entry("Valid HTTP Wildcard Subdomain Defined Port", "http://foo.wildcard.sub.port.bar:8080/redirect", true),
+			Entry("Valid HTTPS Wildcard Subdomain Defined Port", "https://foo.wildcard.sub.port.bar:8080/redirect", true),
+			Entry("Valid HTTP Wildcard Subdomain Defined Port Root", "http://wildcard.sub.port.bar:8080/redirect", true),
+			Entry("Valid HTTPS Wildcard Subdomain Defined Port Root", "https://wildcard.sub.port.bar:8080/redirect", true),
+			Entry("Missing Protocol Root Domain", "foo.bar/redirect", false),
+			Entry("Missing Protocol Wildcard Subdomain", "proxy.wildcard.bar/redirect", false),
 		)
 	})
 
@@ -109,7 +127,7 @@ var _ = Describe("Validator suite", func() {
 
 		DescribeTable("Should split the host and port",
 			func(in splitHostPortTableInput) {
-				host, port := splitHostPort(in.hostport)
+				host, port := util.SplitHostPort(in.hostport)
 				Expect(host).To(Equal(in.expectedHost))
 				Expect(port).To(Equal(in.expectedPort))
 			},
diff --git a/validator_test.go b/validator_test.go
index 5ea8ebf2..0856ed90 100644
--- a/validator_test.go
+++ b/validator_test.go
@@ -153,6 +153,13 @@ func TestValidatorCases(t *testing.T) {
 			email:          "foo.bar@example0.com",
 			expectedAuthZ:  false,
 		},
+		{
+			name:           "EmailNotInCorrect1stSubDomainsNotInEmailsWildcard",
+			allowedEmails:  []string{"xyzzy@example.com", "plugh@example.com"},
+			allowedDomains: []string{"*.example0.com", "*.example1.com"},
+			email:          "foo.bar@example0.com",
+			expectedAuthZ:  false,
+		},
 		{
 			name:           "EmailInFirstDomain",
 			allowedEmails:  []string{"xyzzy@example.com", "plugh@example.com"},
@@ -160,6 +167,13 @@ func TestValidatorCases(t *testing.T) {
 			email:          "foo@bar.example0.com",
 			expectedAuthZ:  true,
 		},
+		{
+			name:           "EmailInFirstDomainWildcard",
+			allowedEmails:  []string{"xyzzy@example.com", "plugh@example.com"},
+			allowedDomains: []string{"*.example0.com", "*.example1.com"},
+			email:          "foo@bar.example0.com",
+			expectedAuthZ:  true,
+		},
 		{
 			name:           "EmailNotInCorrect2ndSubDomainsNotInEmails",
 			allowedEmails:  []string{"xyzzy@example.com", "plugh@example.com"},
@@ -174,6 +188,13 @@ func TestValidatorCases(t *testing.T) {
 			email:          "baz@quux.example1.com",
 			expectedAuthZ:  true,
 		},
+		{
+			name:           "EmailInSecondDomainWildcard",
+			allowedEmails:  []string{"xyzzy@example.com", "plugh@example.com"},
+			allowedDomains: []string{"*.example0.com", "*.example1.com"},
+			email:          "baz@quux.example1.com",
+			expectedAuthZ:  true,
+		},
 		{
 			name:           "EmailInFirstEmailList",
 			allowedEmails:  []string{"xyzzy@example.com", "plugh@example.com"},
@@ -181,6 +202,13 @@ func TestValidatorCases(t *testing.T) {
 			email:          "xyzzy@example.com",
 			expectedAuthZ:  true,
 		},
+		{
+			name:           "EmailInFirstEmailListWildcard",
+			allowedEmails:  []string{"xyzzy@example.com", "plugh@example.com"},
+			allowedDomains: []string{"*.example0.com", "*.example1.com"},
+			email:          "xyzzy@example.com",
+			expectedAuthZ:  true,
+		},
 		{
 			name:           "EmailNotInDomainsNotInEmails",
 			allowedEmails:  []string{"xyzzy@example.com", "plugh@example.com"},
@@ -370,6 +398,13 @@ func TestValidatorCases(t *testing.T) {
 			allowedDomains: []string{"company.com"},
 			expectedAuthZ:  false,
 		},
+		{
+			name:           "CheckForEqualityNotSuffixWildcard",
+			email:          "foo@evilcompany.com",
+			allowedEmails:  []string(nil),
+			allowedDomains: []string{"*.company.com"},
+			expectedAuthZ:  false,
+		},
 	}
 
 	for _, tc := range testCases {