diff --git a/docs/docs/features/endpoints.md b/docs/docs/features/endpoints.md index 5befce18..2977b280 100644 --- a/docs/docs/features/endpoints.md +++ b/docs/docs/features/endpoints.md @@ -40,20 +40,40 @@ BEWARE that the domain you want to redirect to (`my-oidc-provider.example.com` i ### Auth -This endpoint returns 202 Accepted response or a 401 Unauthorized response. +This endpoint is used for Nginx subrequest authentication. It returns the following status codes based on the request state: +* **202 Accepted:** The user is authenticated and passes all authorization logic checks. +* **403 Forbidden:** The user is authenticated but fails the configured logic checks (e.g., wrong group or email domain). +* **401 Unauthorized:** The user is unable to authenticate (missing or invalid session). It can be configured using the following query parameters: - `allowed_groups`: comma separated list of allowed groups - `allowed_email_domains`: comma separated list of allowed email domains - `allowed_emails`: comma separated list of allowed emails +- `allowed_users`: comma separated list of allowed users +- `require_all_matches`: (boolean, default: `true`) Determines if all defined constraints must pass. +- `constraints_required`: (boolean, default: `false`) Determines if the request is denied when no constraints are present. + +**Logic Behavior:** +* **Default (AND Logic):** If multiple constraints are provided (e.g., `allowed_users` AND `allowed_groups`), the user must satisfy **ALL** of them. +* **OR Logic:** If `require_all_matches=false` is set, the user must satisfy **AT LEAST ONE** of the provided constraints. +* **Empty State:** If no constraints are provided, the request is allowed by default. Set `constraints_required=true` to deny requests that do not match at least one specific restriction. ### Proxy (/) -This endpoint returns the upstream response if authenticated. -If unauthenticated it returns a 401 Unauthorized. If the authenticatd user -is not in one of the allowed groups, or emails then it returns a 403 forbidden +This endpoint proxies the request to the upstream service. It returns the following status codes based on the request state: +* **Upstream Response:** The user is authenticated and passes all authorization logic checks. +* **403 Forbidden:** The user is authenticated but fails the configured logic checks. +* **401 Unauthorized:** The user is unable to authenticate. It can be configured using the following query parameters: - `allowed_groups`: comma separated list of allowed groups - `allowed_email_domains`: comma separated list of allowed email domains - `allowed_emails`: comma separated list of allowed emails +- `allowed_users`: comma separated list of allowed users +- `require_all_matches`: (boolean, default: `true`) Determines if all defined constraints must pass. +- `constraints_required`: (boolean, default: `false`) Determines if the request is denied when no constraints are present. + +**Logic Behavior:** +* **Default (AND Logic):** If multiple constraints are provided (e.g., `allowed_users` AND `allowed_groups`), the user must satisfy **ALL** of them. +* **OR Logic:** If `require_all_matches=false` is set, the user must satisfy **AT LEAST ONE** of the provided constraints. +* **Empty State:** If no constraints are provided, the request is allowed by default. Set `constraints_required=true` to deny requests that do not match at least one specific restriction. diff --git a/oauthproxy.go b/oauthproxy.go index 508084c8..ea105c08 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -1154,19 +1154,55 @@ func authOnlyAuthorize(req *http.Request, s *sessionsapi.SessionState) bool { return true } - constraints := []func(*http.Request, *sessionsapi.SessionState) bool{ + // By default, all checks are required to pass (AND logic). + // If `require_all_matches=false` is set, only one check needs to pass (OR logic). + requireAllMatches := !(req.URL.Query().Get("require_all_matches") == "false") + + // By default, if no constraints are set, the request is allowed. + // If `constraints_required=true` is set, at least one constraint must be present + // and pass for the request to be allowed. + constraintsRequired := req.URL.Query().Get("constraints_required") == "true" + + constraints := []func(*http.Request, *sessionsapi.SessionState) (allowed, found bool){ checkAllowedGroups, checkAllowedEmailDomains, checkAllowedEmails, + checkAllowedUsers, } - for _, constraint := range constraints { - if !constraint(req, s) { - return false + var constraintsFound bool + var passedCount int + var failedCount int + + for _, check := range constraints { + allowed, found := check(req, s) + + // We only care about constraints that are actually configured (found) + if found { + constraintsFound = true + if allowed { + passedCount++ + } else { + failedCount++ + } } } - return true + // CASE 1: No constraints were configured/found. + if !constraintsFound { + // By default, allow the request if no constraints are set. + // If constraints are required, deny because none were present. + return !constraintsRequired + } + + // CASE 2: Constraints were found. Apply the combination logic. + if requireAllMatches { + // Default behavior: All configured constraints must pass (AND logic). + return failedCount == 0 + } + + // Alternative behavior: Only one configured constraint needs to pass (OR logic). + return passedCount > 0 } // extractAllowedEntities aims to extract and split allowed entities linked by a key, @@ -1189,15 +1225,15 @@ func extractAllowedEntities(req *http.Request, key string) map[string]struct{} { // checkAllowedEmailDomains allow email domain restrictions based on the `allowed_email_domains` // querystring parameter -func checkAllowedEmailDomains(req *http.Request, s *sessionsapi.SessionState) bool { +func checkAllowedEmailDomains(req *http.Request, s *sessionsapi.SessionState) (allowed, found bool) { allowedEmailDomains := extractAllowedEntities(req, "allowed_email_domains") if len(allowedEmailDomains) == 0 { - return true + return true, false } splitEmail := strings.Split(s.Email, "@") if len(splitEmail) != 2 { - return false + return false, true } endpoint, _ := url.Parse("") @@ -1208,36 +1244,52 @@ func checkAllowedEmailDomains(req *http.Request, s *sessionsapi.SessionState) bo allowedEmailDomainsList = append(allowedEmailDomainsList, ed) } - return util.IsEndpointAllowed(endpoint, allowedEmailDomainsList) + return util.IsEndpointAllowed(endpoint, allowedEmailDomainsList), true } // checkAllowedGroups allow secondary group restrictions based on the `allowed_groups` // querystring parameter -func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) bool { +func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) (allowed, found bool) { allowedGroups := extractAllowedEntities(req, "allowed_groups") if len(allowedGroups) == 0 { - return true + return true, false } for _, group := range s.Groups { if _, ok := allowedGroups[group]; ok { - return true + return true, true } } - return false + return false, true +} + +// checkAllowedUsers allow user restrictions based on the `allowed_users` +// querystring parameter +func checkAllowedUsers(req *http.Request, s *sessionsapi.SessionState) (allowed, found bool) { + allowedUsers := extractAllowedEntities(req, "allowed_users") + if len(allowedUsers) == 0 { + return true, false + } + + for user := range allowedUsers { + if user == s.User { + allowed = true + break + } + } + + return allowed, true } // checkAllowedEmails allow email restrictions based on the `allowed_emails` // querystring parameter -func checkAllowedEmails(req *http.Request, s *sessionsapi.SessionState) bool { +func checkAllowedEmails(req *http.Request, s *sessionsapi.SessionState) (allowed, found bool) { allowedEmails := extractAllowedEntities(req, "allowed_emails") if len(allowedEmails) == 0 { - return true + return true, false } - allowed := false - for email := range allowedEmails { if email == s.Email { allowed = true @@ -1245,7 +1297,7 @@ func checkAllowedEmails(req *http.Request, s *sessionsapi.SessionState) bool { } } - return allowed + return allowed, true } // encodeState builds the OAuth state param out of our nonce and diff --git a/oauthproxy_test.go b/oauthproxy_test.go index ccabdbbd..ed17fa5e 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -3445,6 +3445,182 @@ func TestAuthOnlyAllowedEmails(t *testing.T) { } } +// TestAuthOnlyAuthorize verifies the authorization logic for the AuthOnly endpoint, +// covering individual constraints (User, Email, Group, Domain), boolean logic +// (AND vs OR modes), and comma-separated value parsing. +func TestAuthOnlyAuthorize(t *testing.T) { + testCases := []struct { + name string + user string + email string + groups []string + querystring string + expectedStatusCode int + }{ + // 1. User Constraints + { + name: "User_NoRestriction", + user: "toto", + querystring: "", + expectedStatusCode: http.StatusAccepted, + }, + { + name: "User_Match_Single", + user: "toto", + querystring: "?allowed_users=toto", + expectedStatusCode: http.StatusAccepted, + }, + { + name: "User_NoMatch_Single", + user: "toto", + querystring: "?allowed_users=tete", + expectedStatusCode: http.StatusForbidden, + }, + { + name: "User_Match_CSV", + user: "toto", + querystring: "?allowed_users=tete,toto", + expectedStatusCode: http.StatusAccepted, + }, + { + name: "User_NoMatch_CSV", + user: "toto", + querystring: "?allowed_users=tete,tutu", + expectedStatusCode: http.StatusForbidden, + }, + // 2. Email & Domain Constraints + { + name: "Email_Match", + user: "user", + email: "user@example.com", + querystring: "?allowed_emails=user@example.com", + expectedStatusCode: http.StatusAccepted, + }, + { + name: "Email_NoMatch", + user: "user", + email: "user@example.com", + querystring: "?allowed_emails=admin@example.com", + expectedStatusCode: http.StatusForbidden, + }, + { + name: "Domain_Match", + user: "user", + email: "user@example.com", + querystring: "?allowed_email_domains=example.com", + expectedStatusCode: http.StatusAccepted, + }, + // 3. Group Constraints + { + name: "Group_Match", + user: "user", + groups: []string{"admins", "devs"}, + querystring: "?allowed_groups=admins", + expectedStatusCode: http.StatusAccepted, + }, + { + name: "Group_NoMatch", + user: "user", + groups: []string{"devs"}, + querystring: "?allowed_groups=admins", + expectedStatusCode: http.StatusForbidden, + }, + // 4. Default Logic (Implicit AND) + // All defined constraints must pass. + { + name: "DefaultAND_BothMatch", + user: "toto", + email: "toto@example.com", + querystring: "?allowed_users=toto&allowed_emails=toto@example.com", + expectedStatusCode: http.StatusAccepted, + }, + { + name: "DefaultAND_OneFail", + user: "toto", + email: "toto@example.com", + // User matches, Email fails -> Forbidden + querystring: "?allowed_users=toto&allowed_emails=tete@example.com", + expectedStatusCode: http.StatusForbidden, + }, + { + name: "DefaultAND_BothFail", + user: "toto", + email: "toto@example.com", + querystring: "?allowed_users=tete&allowed_emails=tete@example.com", + expectedStatusCode: http.StatusForbidden, + }, + // 5. Explicit OR Logic (require_all_matches=false) + // Only one defined constraint needs to pass. + { + name: "ORMode_OneMatch_OneFail", + user: "toto", + email: "toto@example.com", + // Email matches, User fails -> Accepted + querystring: "?allowed_emails=toto@example.com&allowed_users=tete&require_all_matches=false", + expectedStatusCode: http.StatusAccepted, + }, + { + name: "ORMode_BothFail", + user: "toto", + email: "toto@example.com", + querystring: "?allowed_users=tete&allowed_emails=tete@example.com&require_all_matches=false", + expectedStatusCode: http.StatusForbidden, + }, + { + name: "ORMode_GroupFail_DomainPass", + user: "user", + email: "user@company.com", + groups: []string{"interns"}, + // User not in 'admins', but email is 'company.com' -> Accepted + querystring: "?allowed_groups=admins&allowed_email_domains=company.com&require_all_matches=false", + expectedStatusCode: http.StatusAccepted, + }, + // 6. Empty State Logic (constraints_required) + { + name: "NoConstraints_Required_True", + user: "user", + querystring: "?constraints_required=true", + expectedStatusCode: http.StatusForbidden, + }, + { + name: "NoConstraints_Required_False", + user: "user", + querystring: "?constraints_required=false", + expectedStatusCode: http.StatusAccepted, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + groups := tc.groups + if groups == nil { + groups = []string{} + } + + created := time.Now() + session := &sessions.SessionState{ + Groups: groups, + User: tc.user, + 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) + }) + } +} + func TestGetOAuthRedirectURI(t *testing.T) { tests := []struct { name string