Add access control logic to the auth and proxy endpoints

Signed-off-by: Antonio Mika <antoniomika@gmail.com>
This commit is contained in:
Antonio Mika 2026-01-26 21:50:02 -05:00
parent 3a55dadbe8
commit d5bf4e059d
3 changed files with 270 additions and 22 deletions

View File

@ -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.

View File

@ -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

View File

@ -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