diff --git a/config.template.yml b/config.template.yml
index 6ced3277a..cea0b6663 100644
--- a/config.template.yml
+++ b/config.template.yml
@@ -131,12 +131,15 @@
# authz:
# forward-auth:
# implementation: 'ForwardAuth'
+ # headers: ~
# authn_strategies: []
# ext-authz:
# implementation: 'ExtAuthz'
+ # headers: ~
# authn_strategies: []
# auth-request:
# implementation: 'AuthRequest'
+ # headers: ~
# authn_strategies: []
# legacy:
# implementation: 'Legacy'
diff --git a/docs/content/configuration/miscellaneous/server-endpoints-authz.md b/docs/content/configuration/miscellaneous/server-endpoints-authz.md
index a3b363221..908db9251 100644
--- a/docs/content/configuration/miscellaneous/server-endpoints-authz.md
+++ b/docs/content/configuration/miscellaneous/server-endpoints-authz.md
@@ -33,6 +33,15 @@ server:
authz:
forward-auth:
implementation: 'ForwardAuth'
+ headers:
+ Remote-User:
+ user_attribute: 'username'
+ Remote-Groups:
+ user_attribute: 'groups'
+ Remote-Name:
+ user_attribute: 'display_name'
+ Remote-Email:
+ user_attribute: 'email'
authn_strategies:
- name: 'HeaderAuthorization'
schemes:
@@ -41,6 +50,15 @@ server:
- name: 'CookieSession'
ext-authz:
implementation: 'ExtAuthz'
+ headers:
+ Remote-User:
+ user_attribute: 'username'
+ Remote-Groups:
+ user_attribute: 'groups'
+ Remote-Name:
+ user_attribute: 'display_name'
+ Remote-Email:
+ user_attribute: 'email'
authn_strategies:
- name: 'HeaderAuthorization'
schemes:
@@ -49,6 +67,15 @@ server:
- name: 'CookieSession'
auth-request:
implementation: 'AuthRequest'
+ headers:
+ Remote-User:
+ user_attribute: 'username'
+ Remote-Groups:
+ user_attribute: 'groups'
+ Remote-Name:
+ user_attribute: 'display_name'
+ Remote-Email:
+ user_attribute: 'email'
authn_strategies:
- name: 'HeaderAuthorization'
schemes:
@@ -80,6 +107,31 @@ The underlying implementation for the endpoint. Valid case-sensitive values are
`AuthRequest`, and `Legacy`. Read more about the implementations in the
[reference guide](../../reference/guides/proxy-authorization.md#implementations).
+### headers
+
+{{< confkey type="dictionary(object)" required="no" >}}
+
+The headers value contains a dictionary where the dictionary key is the header name and the dictionary value is an
+object which determines the header value.
+
+The header name must only contain valid header name characters, and must not be a standard or otherwise reserved header
+such as `Content-Type`, `Location`, `Set-Cookie`, `WWW-Authenticate`, or any of the `Access-Control-*`, `X-Forwarded-*`,
+and `X-Original-*` headers. As header names are case-insensitive the same header must not be configured more than once
+in differing cases.
+
+Configuring this option replaces the default headers entirely, so the `Remote-User`, `Remote-Groups`, `Remote-Name`, and
+`Remote-Email` headers must be included if they are still required.
+
+#### user_attribute
+
+{{< confkey type="string" required="yes" >}}
+
+The user attribute to derive the header value from. This attribute can be one available from the authentication backend
+or one determined via the [User Attribute Definitions](../definitions/user-attributes.md). Attribute names which are not
+known to Authelia, and attributes which are not mapped by the configured authentication backend, cause a startup error.
+
+Attributes which resolve to a list are joined with a comma.
+
### authn_strategies
{{< confkey type="list" required="no" >}}
diff --git a/internal/authentication/types.go b/internal/authentication/types.go
index 13c6fa5c3..41366949d 100644
--- a/internal/authentication/types.go
+++ b/internal/authentication/types.go
@@ -29,6 +29,10 @@ type UserDetails struct {
// Addresses returns the Emails []string as []mail.Address formatted with DisplayName as the Name attribute.
func (d *UserDetails) Addresses() (addresses []mail.Address) {
+ if d == nil {
+ return nil
+ }
+
if len(d.Emails) == 0 {
return nil
}
@@ -47,21 +51,37 @@ func (d *UserDetails) Addresses() (addresses []mail.Address) {
// GetUsername returns the username.
func (d *UserDetails) GetUsername() (username string) {
+ if d == nil {
+ return ""
+ }
+
return d.Username
}
// GetGroups returns the groups.
func (d *UserDetails) GetGroups() (groups []string) {
+ if d == nil {
+ return nil
+ }
+
return d.Groups
}
// GetDisplayName returns the display name.
func (d *UserDetails) GetDisplayName() (name string) {
+ if d == nil {
+ return ""
+ }
+
return d.DisplayName
}
// GetEmails returns the emails.
func (d *UserDetails) GetEmails() (emails []string) {
+ if d == nil {
+ return nil
+ }
+
return d.Emails
}
@@ -87,58 +107,147 @@ type UserDetailsExtended struct {
*UserDetails
}
+// Addresses returns the Emails []string as []mail.Address formatted with DisplayName as the Name attribute.
+func (d *UserDetailsExtended) Addresses() (addresses []mail.Address) {
+ if d == nil {
+ return nil
+ }
+
+ return d.UserDetails.Addresses()
+}
+
+// GetUsername returns the username.
+func (d *UserDetailsExtended) GetUsername() (username string) {
+ if d == nil {
+ return ""
+ }
+
+ return d.UserDetails.GetUsername()
+}
+
+// GetGroups returns the groups.
+func (d *UserDetailsExtended) GetGroups() (groups []string) {
+ if d == nil {
+ return nil
+ }
+
+ return d.UserDetails.GetGroups()
+}
+
+// GetDisplayName returns the display name.
+func (d *UserDetailsExtended) GetDisplayName() (name string) {
+ if d == nil {
+ return ""
+ }
+
+ return d.UserDetails.GetDisplayName()
+}
+
+// GetEmails returns the emails.
+func (d *UserDetailsExtended) GetEmails() (emails []string) {
+ if d == nil {
+ return nil
+ }
+
+ return d.UserDetails.GetEmails()
+}
+
// GetGivenName returns the given name.
func (d *UserDetailsExtended) GetGivenName() (given string) {
+ if d == nil {
+ return ""
+ }
+
return d.GivenName
}
// GetFamilyName returns the family name.
func (d *UserDetailsExtended) GetFamilyName() (family string) {
+ if d == nil {
+ return ""
+ }
+
return d.FamilyName
}
// GetMiddleName returns the middle name.
func (d *UserDetailsExtended) GetMiddleName() (middle string) {
+ if d == nil {
+ return ""
+ }
+
return d.MiddleName
}
// GetNickname returns the nickname.
func (d *UserDetailsExtended) GetNickname() (nickname string) {
+ if d == nil {
+ return ""
+ }
+
return d.Nickname
}
// GetProfile returns the profile URL as a string.
func (d *UserDetailsExtended) GetProfile() (profile string) {
+ if d == nil {
+ return ""
+ }
+
return stringURL(d.Profile)
}
// GetPicture returns the picture URL as a string.
func (d *UserDetailsExtended) GetPicture() (picture string) {
+ if d == nil {
+ return ""
+ }
+
return stringURL(d.Picture)
}
// GetWebsite returns the website URL as a string.
func (d *UserDetailsExtended) GetWebsite() (website string) {
+ if d == nil {
+ return ""
+ }
+
return stringURL(d.Website)
}
// GetGender returns the gender.
func (d *UserDetailsExtended) GetGender() (gender string) {
+ if d == nil {
+ return ""
+ }
+
return d.Gender
}
// GetBirthdate returns the birthdate.
func (d *UserDetailsExtended) GetBirthdate() (birthdate string) {
+ if d == nil {
+ return ""
+ }
+
return d.Birthdate
}
// GetZoneInfo returns the zone information.
func (d *UserDetailsExtended) GetZoneInfo() (info string) {
+ if d == nil {
+ return ""
+ }
+
return d.ZoneInfo
}
// GetLocale returns the locale as a string.
func (d *UserDetailsExtended) GetLocale() (locale string) {
+ if d == nil {
+ return ""
+ }
+
if d.Locale == nil {
return ""
}
@@ -148,16 +257,28 @@ func (d *UserDetailsExtended) GetLocale() (locale string) {
// GetPhoneNumber returns the phone number without the extension.
func (d *UserDetailsExtended) GetPhoneNumber() (number string) {
+ if d == nil {
+ return ""
+ }
+
return d.PhoneNumber
}
// GetPhoneExtension returns the phone extension.
func (d *UserDetailsExtended) GetPhoneExtension() (extension string) {
+ if d == nil {
+ return ""
+ }
+
return d.PhoneExtension
}
// GetPhoneNumberRFC3966 returns the phone number and extension formatted as per RFC3966.
func (d *UserDetailsExtended) GetPhoneNumberRFC3966() (number string) {
+ if d == nil {
+ return ""
+ }
+
if d.PhoneNumber == "" {
return ""
}
@@ -171,6 +292,10 @@ func (d *UserDetailsExtended) GetPhoneNumberRFC3966() (number string) {
// GetStreetAddress returns the street address.
func (d *UserDetailsExtended) GetStreetAddress() (address string) {
+ if d == nil {
+ return ""
+ }
+
if d.Address == nil {
return ""
}
@@ -180,6 +305,10 @@ func (d *UserDetailsExtended) GetStreetAddress() (address string) {
// GetLocality returns the locality.
func (d *UserDetailsExtended) GetLocality() (locality string) {
+ if d == nil {
+ return ""
+ }
+
if d.Address == nil {
return ""
}
@@ -189,6 +318,10 @@ func (d *UserDetailsExtended) GetLocality() (locality string) {
// GetRegion returns the region.
func (d *UserDetailsExtended) GetRegion() (region string) {
+ if d == nil {
+ return ""
+ }
+
if d.Address == nil {
return ""
}
@@ -198,6 +331,10 @@ func (d *UserDetailsExtended) GetRegion() (region string) {
// GetPostalCode returns the postal code.
func (d *UserDetailsExtended) GetPostalCode() (postcode string) {
+ if d == nil {
+ return ""
+ }
+
if d.Address == nil {
return ""
}
@@ -207,6 +344,10 @@ func (d *UserDetailsExtended) GetPostalCode() (postcode string) {
// GetCountry returns the country.
func (d *UserDetailsExtended) GetCountry() (country string) {
+ if d == nil {
+ return ""
+ }
+
if d.Address == nil {
return ""
}
@@ -216,6 +357,10 @@ func (d *UserDetailsExtended) GetCountry() (country string) {
// GetExtra returns the extra attributes.
func (d *UserDetailsExtended) GetExtra() (extra map[string]any) {
+ if d == nil {
+ return nil
+ }
+
return d.Extra
}
diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml
index 6ced3277a..cea0b6663 100644
--- a/internal/configuration/config.template.yml
+++ b/internal/configuration/config.template.yml
@@ -131,12 +131,15 @@
# authz:
# forward-auth:
# implementation: 'ForwardAuth'
+ # headers: ~
# authn_strategies: []
# ext-authz:
# implementation: 'ExtAuthz'
+ # headers: ~
# authn_strategies: []
# auth-request:
# implementation: 'AuthRequest'
+ # headers: ~
# authn_strategies: []
# legacy:
# implementation: 'Legacy'
diff --git a/internal/configuration/schema/const.go b/internal/configuration/schema/const.go
index 8f34ded40..cf4e6c519 100644
--- a/internal/configuration/schema/const.go
+++ b/internal/configuration/schema/const.go
@@ -84,6 +84,20 @@ const (
RefreshIntervalDefault = time.Minute * 5
)
+const (
+ // HeaderRemoteUser is the header which by default communicates the username of the authenticated user.
+ HeaderRemoteUser = "Remote-User"
+
+ // HeaderRemoteGroups is the header which by default communicates the groups of the authenticated user.
+ HeaderRemoteGroups = "Remote-Groups"
+
+ // HeaderRemoteName is the header which by default communicates the display name of the authenticated user.
+ HeaderRemoteName = "Remote-Name"
+
+ // HeaderRemoteEmail is the header which by default communicates the email of the authenticated user.
+ HeaderRemoteEmail = "Remote-Email"
+)
+
const (
// LDAPImplementationCustom is the string for the custom LDAP implementation.
LDAPImplementationCustom = "custom"
diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go
index b7d87b963..ee04af027 100644
--- a/internal/configuration/schema/keys.go
+++ b/internal/configuration/schema/keys.go
@@ -343,6 +343,9 @@ var Keys = []string{
"server.endpoints.authz.*.authn_strategies[].name",
"server.endpoints.authz.*.authn_strategies[].scheme_basic_cache_lifespan",
"server.endpoints.authz.*.authn_strategies[].schemes",
+ "server.endpoints.authz.*.headers",
+ "server.endpoints.authz.*.headers.*",
+ "server.endpoints.authz.*.headers.*.user_attribute",
"server.endpoints.authz.*.implementation",
"server.endpoints.enable_expvars",
"server.endpoints.enable_pprof",
diff --git a/internal/configuration/schema/server.go b/internal/configuration/schema/server.go
index 338818211..fb1de7b31 100644
--- a/internal/configuration/schema/server.go
+++ b/internal/configuration/schema/server.go
@@ -49,9 +49,16 @@ type ServerEndpointHealth struct {
type ServerEndpointsAuthz struct {
Implementation string `koanf:"implementation" yaml:"implementation,omitempty" toml:"implementation,omitempty" json:"implementation,omitempty" jsonschema:"enum=ForwardAuth,enum=AuthRequest,enum=ExtAuthz,enum=Legacy,title=Implementation" jsonschema_description:"The specific Authorization implementation to use for this endpoint."`
+ Headers map[string]ServerEndpointsAuthzHeader `koanf:"headers" yaml:"headers,omitempty" toml:"headers,omitempty" json:"headers,omitempty" jsonschema:"title=Headers" jsonschema_description:"The specific Authorization headers to use for this endpoint."`
+
AuthnStrategies []ServerEndpointsAuthzAuthnStrategy `koanf:"authn_strategies" yaml:"authn_strategies,omitempty" toml:"authn_strategies,omitempty" json:"authn_strategies,omitempty" jsonschema:"title=Authn Strategies" jsonschema_description:"The specific Authorization strategies to use for this endpoint."`
}
+// ServerEndpointsAuthzHeader is the Authz endpoint response header configuration for the HTTP server.
+type ServerEndpointsAuthzHeader struct {
+ UserAttribute string `koanf:"user_attribute" yaml:"user_attribute,omitempty" toml:"user_attribute,omitempty" json:"user_attribute,omitempty" jsonschema:"required,title=User Attribute" jsonschema_description:"The user attribute to use for the header."`
+}
+
// ServerEndpointsAuthzAuthnStrategy is the Authz endpoints configuration for the HTTP server.
type ServerEndpointsAuthzAuthnStrategy struct {
Name string `koanf:"name" yaml:"name,omitempty" toml:"name,omitempty" json:"name,omitempty" jsonschema:"enum=HeaderAuthorization,enum=HeaderProxyAuthorization,enum=HeaderAuthRequestProxyAuthorization,enum=HeaderLegacy,enum=CookieSession,title=Name" jsonschema_description:"The name of the Authorization strategy to use."`
@@ -129,6 +136,20 @@ var DefaultServerConfiguration = Server{
},
AuthzEndpointNameAuthRequest: {
Implementation: AuthzImplementationAuthRequest,
+ Headers: map[string]ServerEndpointsAuthzHeader{
+ HeaderRemoteUser: {
+ UserAttribute: "username",
+ },
+ HeaderRemoteGroups: {
+ UserAttribute: "groups",
+ },
+ HeaderRemoteName: {
+ UserAttribute: "display_name",
+ },
+ HeaderRemoteEmail: {
+ UserAttribute: "email",
+ },
+ },
AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{
{
Name: AuthzStrategyHeaderAuthorization,
@@ -141,6 +162,20 @@ var DefaultServerConfiguration = Server{
},
AuthzEndpointNameExtAuthz: {
Implementation: AuthzImplementationExtAuthz,
+ Headers: map[string]ServerEndpointsAuthzHeader{
+ HeaderRemoteUser: {
+ UserAttribute: "username",
+ },
+ HeaderRemoteGroups: {
+ UserAttribute: "groups",
+ },
+ HeaderRemoteName: {
+ UserAttribute: "display_name",
+ },
+ HeaderRemoteEmail: {
+ UserAttribute: "email",
+ },
+ },
AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{
{
Name: AuthzStrategyHeaderAuthorization,
@@ -153,6 +188,20 @@ var DefaultServerConfiguration = Server{
},
AuthzEndpointNameForwardAuth: {
Implementation: AuthzImplementationForwardAuth,
+ Headers: map[string]ServerEndpointsAuthzHeader{
+ HeaderRemoteUser: {
+ UserAttribute: "username",
+ },
+ HeaderRemoteGroups: {
+ UserAttribute: "groups",
+ },
+ HeaderRemoteName: {
+ UserAttribute: "display_name",
+ },
+ HeaderRemoteEmail: {
+ UserAttribute: "email",
+ },
+ },
AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{
{
Name: AuthzStrategyHeaderAuthorization,
diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go
index 1fc5640a5..25a1556f2 100644
--- a/internal/configuration/validator/const.go
+++ b/internal/configuration/validator/const.go
@@ -445,22 +445,28 @@ const (
errFmtServerPathNotEndForwardSlash = "server: option 'address' must be a single subpath (i.e. '%s'), but '%s' contains multiple segments"
errFmtServerPathAlphaNumeric = "server: option 'address' must have a path with only alphanumeric characters but it's configured as '%s'"
- errFmtServerEndpointsAuthzImplementation = "server: endpoints: authz: %s: option 'implementation' must be one of %s but it's configured as '%s'"
- errFmtServerEndpointsAuthzStrategy = "server: endpoints: authz: %s: authn_strategies: option 'name' must be one of %s but it's configured as '%s'"
- errFmtServerEndpointsAuthzSchemes = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' must only include the values %s but has '%s'"
- errFmtServerEndpointsAuthzSchemesInvalidForStrategy = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' is not valid for the strategy"
- errFmtServerEndpointsAuthzStrategyNoName = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option 'name' must be configured"
- errFmtServerEndpointsAuthzStrategySchemeOnlyOption = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option '%s' can't be configured unless the '%s' scheme is configured but only the %s schemes are configured"
- errFmtServerEndpointsAuthzStrategyDuplicate = "server: endpoints: authz: %s: authn_strategies: duplicate strategy name detected with name '%s'"
- errFmtServerEndpointsAuthzPrefixDuplicate = "server: endpoints: authz: %s: endpoint starts with the same prefix as the '%s' endpoint with the '%s' implementation which accepts prefixes as part of its implementation"
- errFmtServerEndpointsHealthProviderUnknown = "server: endpoints: health: option 'providers' must only include the values %s but it's configured as '%s'"
- errFmtServerEndpointsHealthProviderDuplicate = "server: endpoints: health: option 'providers' has a duplicate value '%s'"
- errFmtServerEndpointsHealthCacheNegative = "server: endpoints: health: option 'cache' must be greater than or equal to 0 but it's configured as '%s'"
- errFmtServerEndpointsHealthDetailedNotVerbose = "server: endpoints: health: option 'detailed' has no effect unless option 'verbose' is enabled"
- errFmtServerEndpointsRateLimitsBucketPeriodZero = "server: endpoints: rate_limits: %s: bucket %d: option 'period' must have a value"
- errFmtServerEndpointsRateLimitsBucketPeriodTooLow = "server: endpoints: rate_limits: %s: bucket %d: option 'period' has a value of '%s' but it must be greater than 10 seconds"
- errFmtServerEndpointsRateLimitsBucketRequestsZero = "server: endpoints: rate_limits: %s: bucket %d: option 'requests' has a value of '%d' but it must be greater than 1"
- errFmtServerEndpointsAuthzInvalidName = "server: endpoints: authz: %s: contains invalid characters"
+ errFmtServerEndpointsAuthzOptionLegacy = "server: endpoints: authz: %s: option '%s' must not be configured for the 'Legacy' implementation"
+ errFmtServerEndpointsAuthzHeaderInvalidName = "server: endpoints: authz: %s: headers: %s: header name must only contain valid header name characters"
+ errFmtServerEndpointsAuthzHeaderReservedName = "server: endpoints: authz: %s: headers: %s: header name must not be a standard or reserved header"
+ errFmtServerEndpointsAuthzHeaderDuplicateName = "server: endpoints: authz: %s: headers: %s: header name duplicates the '%s' header as header names are case-insensitive"
+ errFmtServerEndpointsAuthzHeaderUserAttributeMissing = "server: endpoints: authz: %s: headers: %s: option 'user_attribute' is required"
+ errFmtServerEndpointsAuthzHeaderUserAttribute = "server: endpoints: authz: %s: headers: %s: option 'user_attribute' must be a known user attribute but it's configured as '%s'"
+ errFmtServerEndpointsAuthzImplementation = "server: endpoints: authz: %s: option 'implementation' must be one of %s but it's configured as '%s'"
+ errFmtServerEndpointsAuthzStrategy = "server: endpoints: authz: %s: authn_strategies: option 'name' must be one of %s but it's configured as '%s'"
+ errFmtServerEndpointsAuthzSchemes = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' must only include the values %s but has '%s'"
+ errFmtServerEndpointsAuthzSchemesInvalidForStrategy = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' is not valid for the strategy"
+ errFmtServerEndpointsAuthzStrategyNoName = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option 'name' must be configured"
+ errFmtServerEndpointsAuthzStrategySchemeOnlyOption = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option '%s' can't be configured unless the '%s' scheme is configured but only the %s schemes are configured"
+ errFmtServerEndpointsAuthzStrategyDuplicate = "server: endpoints: authz: %s: authn_strategies: duplicate strategy name detected with name '%s'"
+ errFmtServerEndpointsAuthzPrefixDuplicate = "server: endpoints: authz: %s: endpoint starts with the same prefix as the '%s' endpoint with the '%s' implementation which accepts prefixes as part of its implementation"
+ errFmtServerEndpointsHealthProviderUnknown = "server: endpoints: health: option 'providers' must only include the values %s but it's configured as '%s'"
+ errFmtServerEndpointsHealthProviderDuplicate = "server: endpoints: health: option 'providers' has a duplicate value '%s'"
+ errFmtServerEndpointsHealthCacheNegative = "server: endpoints: health: option 'cache' must be greater than or equal to 0 but it's configured as '%s'"
+ errFmtServerEndpointsHealthDetailedNotVerbose = "server: endpoints: health: option 'detailed' has no effect unless option 'verbose' is enabled"
+ errFmtServerEndpointsRateLimitsBucketPeriodZero = "server: endpoints: rate_limits: %s: bucket %d: option 'period' must have a value"
+ errFmtServerEndpointsRateLimitsBucketPeriodTooLow = "server: endpoints: rate_limits: %s: bucket %d: option 'period' has a value of '%s' but it must be greater than 10 seconds"
+ errFmtServerEndpointsRateLimitsBucketRequestsZero = "server: endpoints: rate_limits: %s: bucket %d: option 'requests' has a value of '%d' but it must be greater than 1"
+ errFmtServerEndpointsAuthzInvalidName = "server: endpoints: authz: %s: contains invalid characters"
errFmtServerEndpointsAuthzLegacyInvalidImplementation = "server: endpoints: authz: %s: option 'implementation' is invalid: the endpoint with the name 'legacy' must use the 'Legacy' implementation"
)
@@ -643,15 +649,20 @@ var (
)
var (
- reKeyReplacer = regexp.MustCompile(`\[\d+]`)
- reDomainCharacters = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$`)
- reAuthzEndpointName = regexp.MustCompile(`^[a-zA-Z](([a-zA-Z0-9/._-]*)([a-zA-Z]))?$`)
- reOpenIDConnectKID = regexp.MustCompile(`^([a-zA-Z0-9](([a-zA-Z0-9._~-]*)([a-zA-Z0-9]))?)?$`)
- reRFC3986Unreserved = regexp.MustCompile(`^[a-zA-Z0-9._~-]+$`)
+ reKeyReplacer = regexp.MustCompile(`\[\d+]`)
+ reDomainCharacters = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$`)
+ reAuthzEndpointName = regexp.MustCompile(`^[a-zA-Z](([a-zA-Z0-9/._-]*)([a-zA-Z]))?$`)
+ reAuthzEndpointHeaderName = regexp.MustCompile("^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$")
+ reOpenIDConnectKID = regexp.MustCompile(`^([a-zA-Z0-9](([a-zA-Z0-9._~-]*)([a-zA-Z0-9]))?)?$`)
+ reRFC3986Unreserved = regexp.MustCompile(`^[a-zA-Z0-9._~-]+$`)
)
const (
attributeUserUsername = "username"
+ attributeUserEmailVerified = "email_verified"
+ attributeUserEmailsExtra = "emails_extra"
+ attributeUserUpdatedAt = "updated_at"
+ attributeUserAddress = "address"
attributeUserGroups = "groups"
attributeUserDisplayName = "display_name"
attributeUserEmail = "email"
@@ -669,13 +680,47 @@ const (
attributeUserLocale = "locale"
attributeUserPhoneNumber = "phone_number"
attributeUserPhoneExtension = "phone_extension"
- attributeUserStreetAddress = "street_address"
- attributeUserLocality = "locality"
- attributeUserRegion = "region"
- attributeUserPostalCode = "postal_code"
- attributeUserCountry = "country"
+
+ attributeUserPhoneNumberRFC3966 = "phone_number_rfc3966"
+ attributeUserPhoneNumberVerified = "phone_number_verified"
+
+ attributeUserStreetAddress = "street_address"
+ attributeUserLocality = "locality"
+ attributeUserRegion = "region"
+ attributeUserPostalCode = "postal_code"
+ attributeUserCountry = "country"
)
+var reservedAuthzEndpointHeaderNames = []string{
+ // Message framing, representation metadata, and hop-by-hop headers.
+ "connection", "content-disposition", "content-encoding", "content-language", "content-length",
+ "content-location", "content-md5", "content-range", "content-type", "date", "expect", "forwarded", "host",
+ "keep-alive", "max-forwards", "proxy-connection", "range", "referer", "server", "te", "trailer",
+ "transfer-encoding", "upgrade", "user-agent", "via", "warning",
+
+ // Authentication, authorization, and session headers.
+ "authorization", "cookie", "proxy-authenticate", "proxy-authorization", "session-username", "set-cookie",
+ "www-authenticate",
+
+ // Redirection, caching, and conditional headers.
+ "age", "cache-control", "etag", "expires", "last-modified", "location", "pragma", "refresh", "retry-after",
+ "vary",
+
+ // Security headers.
+ "alt-svc", "clear-site-data", "content-security-policy", "content-security-policy-report-only", "expect-ct",
+ "feature-policy", "nel", "origin", "origin-agent-cluster", "permissions-policy", "referrer-policy",
+ "report-to", "strict-transport-security", "timing-allow-origin", "x-content-type-options",
+ "x-dns-prefetch-control", "x-download-options", "x-frame-options", "x-permitted-cross-domain-policies",
+ "x-xss-protection",
+
+ // Headers utilized by Authelia and the proxies which integrate with it.
+ "x-authelia-url", "x-real-ip",
+}
+
+var reservedAuthzEndpointHeaderPrefixes = []string{
+ "access-control-", "if-", "sec-", "x-forwarded-", "x-original-",
+}
+
var validUserAttributes = []string{
attributeUserUsername,
attributeUserGroups,
diff --git a/internal/configuration/validator/server.go b/internal/configuration/validator/server.go
index a4ab120f6..a0bde0052 100644
--- a/internal/configuration/validator/server.go
+++ b/internal/configuration/validator/server.go
@@ -389,8 +389,6 @@ func validateServerEndpointsAuthzEndpoint(config *schema.Configuration, name str
break
case "":
endpoint.Implementation = schema.AuthzImplementationLegacy
-
- config.Server.Endpoints.Authz[name] = endpoint
default:
if !utils.IsStringInSlice(endpoint.Implementation, validAuthzImplementations) {
validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzImplementation, name, utils.StringJoinOr(validAuthzImplementations), endpoint.Implementation))
@@ -405,6 +403,104 @@ func validateServerEndpointsAuthzEndpoint(config *schema.Configuration, name str
if !reAuthzEndpointName.MatchString(name) {
validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzInvalidName, name))
}
+
+ // NOTE: With exclusion of the implementation set any options below this line.
+ if endpoint.Implementation == schema.AuthzImplementationLegacy && endpoint.Headers != nil {
+ validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzOptionLegacy, name, "headers"))
+ }
+
+ if endpoint.Headers == nil {
+ switch endpoint.Implementation {
+ case schema.AuthzImplementationLegacy:
+ break
+ case schema.AuthzImplementationAuthRequest:
+ endpoint.Headers = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameAuthRequest].Headers
+ case schema.AuthzImplementationExtAuthz:
+ endpoint.Headers = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameExtAuthz].Headers
+ case schema.AuthzImplementationForwardAuth:
+ endpoint.Headers = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].Headers
+ }
+ } else {
+ validateServerEndpointsAuthzEndpointHeaders(config, name, endpoint, validator)
+ }
+
+ config.Server.Endpoints.Authz[name] = endpoint
+}
+
+func validateServerEndpointsAuthzEndpointHeaders(config *schema.Configuration, name string, endpoint schema.ServerEndpointsAuthz, validator *schema.StructValidator) {
+ headers := make([]string, 0, len(endpoint.Headers))
+
+ for header := range endpoint.Headers {
+ headers = append(headers, header)
+ }
+
+ sort.Strings(headers)
+
+ seen := make(map[string]string, len(headers))
+
+ for _, header := range headers {
+ validateServerEndpointsAuthzEndpointHeaderName(name, header, seen, validator)
+
+ switch attribute := endpoint.Headers[header].UserAttribute; {
+ case attribute == "":
+ validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderUserAttributeMissing, name, header))
+ case !isUserAttributeValidAuthz(attribute, config):
+ validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderUserAttribute, name, header, attribute))
+ }
+ }
+}
+
+func validateServerEndpointsAuthzEndpointHeaderName(name, header string, seen map[string]string, validator *schema.StructValidator) {
+ if !reAuthzEndpointHeaderName.MatchString(header) {
+ validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderInvalidName, name, header))
+
+ return
+ }
+
+ key := strings.ToLower(header)
+
+ if first, ok := seen[key]; ok {
+ validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderDuplicateName, name, header, first))
+
+ return
+ }
+
+ seen[key] = header
+
+ if utils.IsStringInSlice(key, reservedAuthzEndpointHeaderNames) {
+ validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderReservedName, name, header))
+
+ return
+ }
+
+ for _, prefix := range reservedAuthzEndpointHeaderPrefixes {
+ if strings.HasPrefix(key, prefix) {
+ validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderReservedName, name, header))
+
+ return
+ }
+ }
+}
+
+func isUserAttributeValidAuthz(name string, config *schema.Configuration) (valid bool) {
+ if _, ok := config.Definitions.UserAttributes[name]; ok {
+ return true
+ }
+
+ switch name {
+ case attributeUserEmailVerified, attributeUserEmailsExtra, attributeUserUpdatedAt:
+ return true
+ case attributeUserPhoneNumberRFC3966, attributeUserPhoneNumberVerified:
+ return isUserAttributeValid(attributeUserPhoneNumber, config)
+ case attributeUserAddress:
+ return isUserAttributeValid(attributeUserStreetAddress, config) ||
+ isUserAttributeValid(attributeUserLocality, config) ||
+ isUserAttributeValid(attributeUserRegion, config) ||
+ isUserAttributeValid(attributeUserPostalCode, config) ||
+ isUserAttributeValid(attributeUserCountry, config)
+ default:
+ return isUserAttributeValid(name, config)
+ }
}
//nolint:gocyclo
diff --git a/internal/configuration/validator/server_test.go b/internal/configuration/validator/server_test.go
index a44f213de..6c158db6a 100644
--- a/internal/configuration/validator/server_test.go
+++ b/internal/configuration/validator/server_test.go
@@ -744,6 +744,7 @@ func TestServerAuthzEndpointDefaults(t *testing.T) {
map[string]schema.ServerEndpointsAuthz{
"example": {
Implementation: "ForwardAuth",
+ Headers: schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].Headers,
AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{
{
Name: "HeaderAuthorization",
@@ -762,6 +763,7 @@ func TestServerAuthzEndpointDefaults(t *testing.T) {
map[string]schema.ServerEndpointsAuthz{
"example": {
Implementation: "ForwardAuth",
+ Headers: schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].Headers,
AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{
{
Name: "HeaderAuthorization",
@@ -1078,3 +1080,201 @@ func TestValidateServerEndpointsHealth(t *testing.T) {
})
}
}
+
+func TestServerAuthzEndpointHeaders(t *testing.T) {
+ testCases := []struct {
+ name string
+ setup func(config *schema.Configuration)
+ headers map[string]schema.ServerEndpointsAuthzHeader
+ errs []string
+ }{
+ {
+ name: "ShouldAllowDefaultAttributes",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ schema.HeaderRemoteUser: {UserAttribute: "username"},
+ schema.HeaderRemoteGroups: {UserAttribute: "groups"},
+ schema.HeaderRemoteName: {UserAttribute: "display_name"},
+ schema.HeaderRemoteEmail: {UserAttribute: "email"},
+ },
+ },
+ {
+ name: "ShouldAllowDerivedAttributes",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-Emails-Extra": {UserAttribute: "emails_extra"},
+ "Remote-Email-Verified": {UserAttribute: "email_verified"},
+ "Remote-Updated-At": {UserAttribute: "updated_at"},
+ "Remote-Address": {UserAttribute: "address"},
+ "Remote-Phone-Number-RFC3966": {UserAttribute: "phone_number_rfc3966"},
+ "Remote-Phone-Number-Verified": {UserAttribute: "phone_number_verified"},
+ },
+ },
+ {
+ name: "ShouldAllowExtraAttributeFromFileBackend",
+ setup: func(config *schema.Configuration) {
+ config.AuthenticationBackend.File.ExtraAttributes = map[string]schema.AuthenticationBackendExtraAttribute{
+ "employee_id": {ValueType: "integer"},
+ }
+ },
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-Employee-Id": {UserAttribute: "employee_id"},
+ },
+ },
+ {
+ name: "ShouldAllowDefinedUserAttribute",
+ setup: func(config *schema.Configuration) {
+ config.Definitions.UserAttributes = map[string]schema.UserAttribute{
+ "is_admin": {Expression: "'admins' in groups"},
+ }
+ },
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-Is-Admin": {UserAttribute: "is_admin"},
+ },
+ },
+ {
+ name: "ShouldErrorOnUnknownAttributes",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-Mail": {UserAttribute: "mail"},
+ "Remote-Teams": {UserAttribute: "teams"},
+ },
+ errs: []string{
+ "server: endpoints: authz: example: headers: Remote-Mail: option 'user_attribute' must be a known user attribute but it's configured as 'mail'",
+ "server: endpoints: authz: example: headers: Remote-Teams: option 'user_attribute' must be a known user attribute but it's configured as 'teams'",
+ },
+ },
+ {
+ name: "ShouldErrorOnInvalidHeaderNames",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote User": {UserAttribute: "username"},
+ "Remote:Group": {UserAttribute: "groups"},
+ },
+ errs: []string{
+ "server: endpoints: authz: example: headers: Remote User: header name must only contain valid header name characters",
+ "server: endpoints: authz: example: headers: Remote:Group: header name must only contain valid header name characters",
+ },
+ },
+ {
+ name: "ShouldErrorOnReservedHeaderNames",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Content-Type": {UserAttribute: "username"},
+ "Location": {UserAttribute: "username"},
+ "set-cookie": {UserAttribute: "username"},
+ "WWW-Authenticate": {UserAttribute: "username"},
+ "X-Authelia-URL": {UserAttribute: "username"},
+ },
+ errs: []string{
+ "server: endpoints: authz: example: headers: Content-Type: header name must not be a standard or reserved header",
+ "server: endpoints: authz: example: headers: Location: header name must not be a standard or reserved header",
+ "server: endpoints: authz: example: headers: WWW-Authenticate: header name must not be a standard or reserved header",
+ "server: endpoints: authz: example: headers: X-Authelia-URL: header name must not be a standard or reserved header",
+ "server: endpoints: authz: example: headers: set-cookie: header name must not be a standard or reserved header",
+ },
+ },
+ {
+ name: "ShouldErrorOnReservedHeaderNamePrefixes",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Access-Control-Allow-Origin": {UserAttribute: "username"},
+ "Sec-Fetch-Site": {UserAttribute: "username"},
+ "X-Forwarded-User": {UserAttribute: "username"},
+ "X-Original-User": {UserAttribute: "username"},
+ },
+ errs: []string{
+ "server: endpoints: authz: example: headers: Access-Control-Allow-Origin: header name must not be a standard or reserved header",
+ "server: endpoints: authz: example: headers: Sec-Fetch-Site: header name must not be a standard or reserved header",
+ "server: endpoints: authz: example: headers: X-Forwarded-User: header name must not be a standard or reserved header",
+ "server: endpoints: authz: example: headers: X-Original-User: header name must not be a standard or reserved header",
+ },
+ },
+ {
+ name: "ShouldErrorOnDuplicateHeaderNames",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-User": {UserAttribute: "username"},
+ "remote-user": {UserAttribute: "display_name"},
+ },
+ errs: []string{
+ "server: endpoints: authz: example: headers: remote-user: header name duplicates the 'Remote-User' header as header names are case-insensitive",
+ },
+ },
+ {
+ name: "ShouldAllowUncommonButValidHeaderNames",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote_User": {UserAttribute: "username"},
+ "X-Team+Name": {UserAttribute: "display_name"},
+ "Remote-Groups": {UserAttribute: "groups"},
+ },
+ },
+ {
+ name: "ShouldErrorOnMissingUserAttribute",
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-User": {},
+ },
+ errs: []string{
+ "server: endpoints: authz: example: headers: Remote-User: option 'user_attribute' is required",
+ },
+ },
+ {
+ name: "ShouldErrorOnAttributeNotMappedByLDAPBackend",
+ setup: func(config *schema.Configuration) {
+ config.AuthenticationBackend.File = nil
+ config.AuthenticationBackend.LDAP = &schema.AuthenticationBackendLDAP{}
+ },
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-Given-Name": {UserAttribute: "given_name"},
+ },
+ errs: []string{
+ "server: endpoints: authz: example: headers: Remote-Given-Name: option 'user_attribute' must be a known user attribute but it's configured as 'given_name'",
+ },
+ },
+ {
+ name: "ShouldAllowAttributeMappedByLDAPBackend",
+ setup: func(config *schema.Configuration) {
+ config.AuthenticationBackend.File = nil
+ config.AuthenticationBackend.LDAP = &schema.AuthenticationBackendLDAP{}
+ config.AuthenticationBackend.LDAP.Attributes.GivenName = "givenName"
+ config.AuthenticationBackend.LDAP.Attributes.PhoneNumber = "telephoneNumber"
+ },
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-Given-Name": {UserAttribute: "given_name"},
+ "Remote-Phone-Number-RFC3966": {UserAttribute: "phone_number_rfc3966"},
+ "Remote-Updated-At": {UserAttribute: "updated_at"},
+ },
+ },
+ {
+ name: "ShouldErrorOnDerivedAddressNotMappedByLDAPBackend",
+ setup: func(config *schema.Configuration) {
+ config.AuthenticationBackend.File = nil
+ config.AuthenticationBackend.LDAP = &schema.AuthenticationBackendLDAP{}
+ },
+ headers: map[string]schema.ServerEndpointsAuthzHeader{
+ "Remote-Address": {UserAttribute: "address"},
+ },
+ errs: []string{
+ "server: endpoints: authz: example: headers: Remote-Address: option 'user_attribute' must be a known user attribute but it's configured as 'address'",
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ validator := schema.NewStructValidator()
+
+ config := newDefaultConfig()
+
+ if tc.setup != nil {
+ tc.setup(&config)
+ }
+
+ config.Server.Endpoints.Authz = map[string]schema.ServerEndpointsAuthz{
+ "example": {Implementation: schema.AuthzImplementationForwardAuth, Headers: tc.headers},
+ }
+
+ ValidateServerEndpoints(&config, validator)
+
+ assert.Len(t, validator.Warnings(), 0)
+ require.Len(t, validator.Errors(), len(tc.errs))
+
+ for i, expected := range tc.errs {
+ assert.EqualError(t, validator.Errors()[i], expected)
+ }
+ })
+ }
+}
diff --git a/internal/handlers/const.go b/internal/handlers/const.go
index dcd818631..005ca163d 100644
--- a/internal/handlers/const.go
+++ b/internal/handlers/const.go
@@ -9,6 +9,7 @@ import (
"github.com/valyala/fasthttp"
+ "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/oidc"
)
@@ -29,10 +30,10 @@ var (
headerProxyAuthenticate = []byte(fasthttp.HeaderProxyAuthenticate)
headerSessionUsername = []byte("Session-Username")
- headerRemoteUser = []byte("Remote-User")
- headerRemoteGroups = []byte("Remote-Groups")
- headerRemoteName = []byte("Remote-Name")
- headerRemoteEmail = []byte("Remote-Email")
+ headerRemoteUser = []byte(schema.HeaderRemoteUser)
+ headerRemoteGroups = []byte(schema.HeaderRemoteGroups)
+ headerRemoteName = []byte(schema.HeaderRemoteName)
+ headerRemoteEmail = []byte(schema.HeaderRemoteEmail)
)
const (
diff --git a/internal/handlers/handler_authz.go b/internal/handlers/handler_authz.go
index 796de94d9..a5b549b98 100644
--- a/internal/handlers/handler_authz.go
+++ b/internal/handlers/handler_authz.go
@@ -206,8 +206,8 @@ func (authz *Authz) Handler(ctx AuthzContext) {
ruleHasSubject, required := ctx.GetProviders().Authorizer.GetRequiredLevel(
authorization.Subject{
- Username: authn.Details.Username,
- Groups: authn.Details.Groups,
+ Username: authn.Details.GetUsername(),
+ Groups: authn.Details.GetGroups(),
ClientID: authn.ClientID,
IP: ctx.RemoteIP(),
},
@@ -251,7 +251,7 @@ func (authz *Authz) Handler(ctx AuthzContext) {
handler(ctx, authn, authz.getRedirectionURL(&object, autheliaURL))
case AuthzResultAuthorized:
- authz.handleAuthorized(ctx, authn)
+ authz.handleAuthorized(ctx, authz.headers, authn)
}
}
@@ -307,7 +307,7 @@ func (authz *Authz) authn(ctx AuthzContext, manager session.Manager, object *aut
authn.Level = authentication.NotAuthenticated
authn.Username = anonymous
authn.ClientID = ""
- authn.Details = authentication.UserDetails{}
+ authn.Details = &authentication.UserDetailsExtended{UserDetails: &authentication.UserDetails{}}
if strategy.CanHandleUnauthorized() {
return authn, strategy, err
@@ -321,7 +321,7 @@ func (authz *Authz) authn(ctx AuthzContext, manager session.Manager, object *aut
}
}
- if strategy.CanHandleUnauthorized() {
+ if strategy != nil && strategy.CanHandleUnauthorized() {
return authn, strategy, err
}
diff --git a/internal/handlers/handler_authz_authn.go b/internal/handlers/handler_authz_authn.go
index 256945ccd..9ae963f83 100644
--- a/internal/handlers/handler_authz_authn.go
+++ b/internal/handlers/handler_authz_authn.go
@@ -93,9 +93,13 @@ func NewHeaderLegacyAuthnStrategy() *HeaderLegacyAuthnStrategy {
// CookieSessionAuthnStrategy is a session cookie AuthnStrategy.
type CookieSessionAuthnStrategy struct {
refresh schema.RefreshIntervalDuration
+
+ extended bool
}
// Get returns the Authn information for this AuthnStrategy.
+//
+//nolint:gocyclo
func (s *CookieSessionAuthnStrategy) Get(ctx AuthzContext, manager session.Manager, _ *authorization.Object) (authn *Authn, err error) {
var userSession session.UserSession
@@ -142,16 +146,47 @@ func (s *CookieSessionAuthnStrategy) Get(ctx AuthzContext, manager session.Manag
}
}
+ if !s.extended || userSession.Username == "" {
+ return &Authn{
+ Username: friendlyUsername(userSession.Username),
+ Details: &authentication.UserDetailsExtended{
+ UserDetails: &authentication.UserDetails{
+ Username: userSession.Username,
+ DisplayName: userSession.DisplayName,
+ Emails: userSession.Emails,
+ Groups: userSession.Groups,
+ },
+ },
+ Level: userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA),
+ Type: AuthnTypeCookie,
+ }, nil
+ }
+
+ var details *authentication.UserDetailsExtended
+
+ if details, err = ctx.GetUserProvider().GetDetailsExtended(userSession.Username); err != nil {
+ if errors.Is(err, authentication.ErrUserNotFound) {
+ ctx.GetLogger().WithField("username", userSession.Username).Error("Error occurred while attempting to get user details for user: the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login")
+
+ return authn, err
+ }
+
+ return authn, fmt.Errorf("unable to retrieve details for user '%s': %w", userSession.Username, err)
+ } else if details == nil || details.GetUsername() == "" {
+ ctx.GetLogger().WithField("username", userSession.Username).Error("Error occurred while attempting to get user details for user: no user details were returned")
+
+ if err = manager.DestroySession(); err != nil {
+ ctx.GetLogger().WithError(err).Errorf("Unable to destroy user session")
+ }
+
+ return authn, fmt.Errorf("unable to retrieve details for user '%s': no user details were returned", userSession.Username)
+ }
+
return &Authn{
Username: friendlyUsername(userSession.Username),
- Details: authentication.UserDetails{
- Username: userSession.Username,
- DisplayName: userSession.DisplayName,
- Emails: userSession.Emails,
- Groups: userSession.Groups,
- },
- Level: userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA),
- Type: AuthnTypeCookie,
+ Details: details,
+ Level: userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA),
+ Type: AuthnTypeCookie,
}, nil
}
@@ -180,6 +215,8 @@ type HeaderAuthnStrategy struct {
delay middlewares.Delayer
basic BasicAuthHandler
+
+ extended bool
}
// BasicAuthHandler is a function signature that handles basic authentication. This is used to implement caching. The
@@ -252,13 +289,13 @@ func (s *HeaderAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *a
return authn, nil
}
- var details *authentication.UserDetails
+ var details *authentication.UserDetailsExtended
switch scheme {
case model.AuthorizationSchemeBasic:
- details, level, err = handleGetBasic(ctx, s.delay, authn, object, s.headerAuthorize, s.basic)
+ details, level, err = handleGetBasic(ctx, s.delay, authn, object, s.headerAuthorize, s.basic, s.extended)
case model.AuthorizationSchemeBearer:
- details, clientID, ccs, level, err = handleVerifyGETAuthorizationBearer(ctx, authn, object)
+ details, clientID, ccs, level, err = handleVerifyGETAuthorizationBearer(ctx, authn, object, s.extended)
default:
ctx.GetLogger().
WithFields(map[string]any{"scheme": authn.Header.Authorization.SchemeRaw(), "header": string(s.headerAuthorize)}).
@@ -288,7 +325,7 @@ func (s *HeaderAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *a
return authn, fmt.Errorf("failed to determine username from the %s header", s.headerAuthorize)
default:
authn.Username = friendlyUsername(details.Username)
- authn.Details = *details
+ authn.Details = details
}
authn.Level = level
@@ -322,6 +359,8 @@ func (s *HeaderAuthnStrategy) HandleUnauthorized(ctx AuthzContext, authn *Authn,
// HeaderLegacyAuthnStrategy is a legacy header AuthnStrategy which can be switched based on the query parameters.
type HeaderLegacyAuthnStrategy struct {
delay middlewares.Delayer
+
+ extended bool
}
// Get returns the Authn information for this AuthnStrategy.
@@ -373,16 +412,16 @@ func (s *HeaderLegacyAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, obj
}
var (
- details *authentication.UserDetails
+ details *authentication.UserDetailsExtended
level authentication.Level
)
- if details, level, err = handleGetBasic(ctx, s.delay, authn, object, header, DefaultBasicAuthHandler); err != nil {
+ if details, level, err = handleGetBasic(ctx, s.delay, authn, object, header, DefaultBasicAuthHandler, s.extended); err != nil {
return authn, fmt.Errorf("failed to validate %s header with %s scheme: %w", header, scheme, err)
}
authn.Username = friendlyUsername(details.Username)
- authn.Details = *details
+ authn.Details = details
authn.Level = level
return authn, nil
@@ -403,7 +442,7 @@ func (s *HeaderLegacyAuthnStrategy) HandleUnauthorized(ctx AuthzContext, authn *
handleAuthzUnauthorizedAuthorizationBasic(ctx, authn)
}
-func handleGetBasic(ctx AuthzContext, delayer middlewares.Delayer, authn *Authn, object *authorization.Object, header []byte, validate BasicAuthHandler) (details *authentication.UserDetails, level authentication.Level, err error) {
+func handleGetBasic(ctx AuthzContext, delayer middlewares.Delayer, authn *Authn, object *authorization.Object, header []byte, validate BasicAuthHandler, extended bool) (details *authentication.UserDetailsExtended, level authentication.Level, err error) {
var (
ban regulation.BanType
value string
@@ -421,7 +460,7 @@ func handleGetBasic(ctx AuthzContext, delayer middlewares.Delayer, authn *Authn,
return nil, authentication.NotAuthenticated, fmt.Errorf("failed to validate parsed credentials of %s header: the username or password was empty", header)
}
- if details, err = ctx.GetUserProvider().GetDetails(username); err != nil {
+ if details, err = handleGetUserDetails(ctx, username, extended); err != nil {
if errors.Is(err, authentication.ErrUserNotFound) {
doMarkAuthenticationAttemptWithRequest(ctx, false, regulation.NewBan(regulation.BanTypeUnknown, "", nil), regulation.AuthType1FA, object.String(), object.Method, err)
@@ -472,9 +511,27 @@ func handleGetBasic(ctx AuthzContext, delayer middlewares.Delayer, authn *Authn,
return details, authentication.OneFactor, nil
}
+func handleGetUserDetails(ctx AuthzContext, username string, extended bool) (details *authentication.UserDetailsExtended, err error) {
+ if extended {
+ return ctx.GetUserProvider().GetDetailsExtended(username)
+ }
+
+ var basic *authentication.UserDetails
+
+ if basic, err = ctx.GetUserProvider().GetDetails(username); err != nil {
+ return nil, err
+ }
+
+ if basic == nil {
+ return nil, nil
+ }
+
+ return &authentication.UserDetailsExtended{UserDetails: basic}, nil
+}
+
func handleAuthnCookieValidate(ctx AuthzContext, manager session.Manager, userSession *session.UserSession, refresh schema.RefreshIntervalDuration) (modified, invalid bool) {
// TODO: Remove this check as it's no longer possible i.e. ineffectual.
- isAnonymous := userSession.Username == ""
+ isAnonymous := userSession.IsAnonymous()
if isAnonymous && userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA) != authentication.NotAuthenticated {
ctx.GetLogger().WithFields(map[string]any{"username": anonymous, "level": userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA).String()}).Errorf("Session for user has an invalid authentication level: this may be a sign of a compromise")
@@ -578,7 +635,7 @@ func handleSessionValidateRefresh(ctx AuthzContext, userSession *session.UserSes
return true, false
}
-func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object *authorization.Object) (details *authentication.UserDetails, clientID string, ccs bool, level authentication.Level, err error) {
+func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object *authorization.Object, extended bool) (details *authentication.UserDetailsExtended, clientID string, ccs bool, level authentication.Level, err error) {
var at bool
if at, err = oidc.IsAccessToken(ctx, authn.Header.Authorization.Value()); !at {
@@ -597,15 +654,15 @@ func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object *
return nil, "", false, authentication.NotAuthenticated, err
}
- return handleVerifyGETAuthorizationBearerResolveUser(ctx, username, clientID, ccs, level)
+ return handleVerifyGETAuthorizationBearerResolveUser(ctx, username, clientID, ccs, level, extended)
}
-func handleVerifyGETAuthorizationBearerResolveUser(ctx AuthzContext, username, clientID string, ccs bool, level authentication.Level) (details *authentication.UserDetails, clientIDOut string, ccsOut bool, levelOut authentication.Level, err error) {
+func handleVerifyGETAuthorizationBearerResolveUser(ctx AuthzContext, username, clientID string, ccs bool, level authentication.Level, extended bool) (details *authentication.UserDetailsExtended, clientIDOut string, ccsOut bool, levelOut authentication.Level, err error) {
if ccs {
return nil, clientID, ccs, level, nil
}
- if details, err = ctx.GetUserProvider().GetDetails(username); err != nil {
+ if details, err = handleGetUserDetails(ctx, username, extended); err != nil {
if errors.Is(err, authentication.ErrUserNotFound) {
ctx.GetLogger().WithField("username", username).Error("Error occurred while attempting to get user details for user: the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login")
}
diff --git a/internal/handlers/handler_authz_builder.go b/internal/handlers/handler_authz_builder.go
index 1a9abc974..5394f18c1 100644
--- a/internal/handlers/handler_authz_builder.go
+++ b/internal/handlers/handler_authz_builder.go
@@ -73,9 +73,18 @@ func (b *AuthzBuilder) WithConfig(config *schema.Configuration) *AuthzBuilder {
return b
}
+// WithEndpointHeaders configures the AuthzBuilder with the response headers for this endpoint.
+func (b *AuthzBuilder) WithEndpointHeaders(headers map[string]schema.ServerEndpointsAuthzHeader) *AuthzBuilder {
+ b.headers = headers
+
+ return b
+}
+
// WithEndpointConfig configures the AuthzBuilder with a *schema.ServerAuthzEndpointConfig. Should be called AFTER
// WithConfig or WithAuthzConfig.
func (b *AuthzBuilder) WithEndpointConfig(config schema.ServerEndpointsAuthz) *AuthzBuilder {
+ b.WithEndpointHeaders(config.Headers)
+
switch config.Implementation {
case AuthzImplForwardAuth.String():
b.WithImplementationForwardAuth()
@@ -129,9 +138,27 @@ func (b *AuthzBuilder) Build() (authz *Authz) {
}
}
+ for header, config := range b.headers {
+ authz.headers = append(authz.headers, AuthzHeader{Key: []byte(header), Attribute: config.UserAttribute})
+ }
+
+ extended := authzHeadersRequireExtendedUserDetails(authz.headers)
+
+ for _, v := range authz.strategies {
+ switch strategy := v.(type) {
+ case *CookieSessionAuthnStrategy:
+ strategy.extended = extended
+ case *HeaderAuthnStrategy:
+ strategy.extended = extended
+ case *HeaderLegacyAuthnStrategy:
+ strategy.extended = extended
+ }
+ }
+
switch b.implementation {
case AuthzImplLegacy:
authz.config.StatusCodeBadRequest = fasthttp.StatusUnauthorized
+ authz.handleAuthorized = handleAuthzAuthorizedLegacy
authz.handleGetObject = handleAuthzGetObjectLegacy
authz.handleUnauthorized = handleAuthzUnauthorizedLegacy
authz.handleGetAutheliaURL = handleAuthzPortalURLLegacy
diff --git a/internal/handlers/handler_authz_common.go b/internal/handlers/handler_authz_common.go
index ba41538b3..bf8354726 100644
--- a/internal/handlers/handler_authz_common.go
+++ b/internal/handlers/handler_authz_common.go
@@ -35,19 +35,40 @@ func handleAuthzPortalURLFromQueryLegacy(ctx AuthzContext) (portalURL *url.URL,
return parseAuthzPortalURL(ctx.GetRequestQueryArgValue(qryArgRD))
}
-func handleAuthzAuthorizedStandard(ctx AuthzContext, authn *Authn) {
+func handleAuthzAuthorizedStandard(ctx AuthzContext, headers []AuthzHeader, authn *Authn) {
ctx.ReplyStatusCode(fasthttp.StatusOK)
- if authn.Details.Username != "" {
- ctx.SetResponseHeaderValue(headerRemoteUser, authn.Details.Username)
- ctx.SetResponseHeaderValue(headerRemoteGroups, strings.Join(authn.Details.Groups, ","))
- ctx.SetResponseHeaderValue(headerRemoteName, authn.Details.DisplayName)
+ if authn.Details.GetUsername() == "" {
+ return
+ }
- switch len(authn.Details.Emails) {
+ resolver, updated := ctx.GetProviderUserAttributeResolver(), ctx.GetClock().Now()
+
+ for _, header := range headers {
+ object, ok := resolver.Resolve(header.Attribute, authn.Details, updated)
+ if !ok {
+ ctx.SetResponseHeaderValue(header.Key, "")
+
+ continue
+ }
+
+ ctx.SetResponseHeaderValue(header.Key, authzHeaderValue(object))
+ }
+}
+
+func handleAuthzAuthorizedLegacy(ctx AuthzContext, _ []AuthzHeader, authn *Authn) {
+ ctx.ReplyStatusCode(fasthttp.StatusOK)
+
+ if authn.Details.GetUsername() != "" {
+ ctx.SetResponseHeaderValue(headerRemoteUser, authn.Details.GetUsername())
+ ctx.SetResponseHeaderValue(headerRemoteGroups, strings.Join(authn.Details.GetGroups(), ","))
+ ctx.SetResponseHeaderValue(headerRemoteName, authn.Details.GetDisplayName())
+
+ switch emails := authn.Details.GetEmails(); len(emails) {
case 0:
ctx.SetResponseHeaderValue(headerRemoteEmail, "")
default:
- ctx.SetResponseHeaderValue(headerRemoteEmail, authn.Details.Emails[0])
+ ctx.SetResponseHeaderValue(headerRemoteEmail, emails[0])
}
}
}
diff --git a/internal/handlers/handler_authz_misc_test.go b/internal/handlers/handler_authz_misc_test.go
index 60fe3583d..a763fc0b0 100644
--- a/internal/handlers/handler_authz_misc_test.go
+++ b/internal/handlers/handler_authz_misc_test.go
@@ -93,7 +93,7 @@ func TestHandleGetBasicShouldRejectEmptyCredentialsWithDelay(t *testing.T) {
object := authorization.NewObject(targetURL, fasthttp.MethodGet)
- details, level, err := handleGetBasic(mock.Ctx, delayer, authn, &object, headerProxyAuthorization, DefaultBasicAuthHandler)
+ details, level, err := handleGetBasic(mock.Ctx, delayer, authn, &object, headerProxyAuthorization, DefaultBasicAuthHandler, false)
require.EqualError(t, err, tc.ExpectError)
assert.Nil(t, details)
@@ -120,8 +120,9 @@ func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) {
ClientID string
CCS bool
Level authentication.Level
+ Extended bool
Setup func(mock *mocks.MockAutheliaCtx)
- ExpectDetails *authentication.UserDetails
+ ExpectDetails *authentication.UserDetailsExtended
ExpectError string
}{
{
@@ -132,6 +133,7 @@ func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) {
Level: authentication.OneFactor,
Setup: func(mock *mocks.MockAutheliaCtx) {
mock.UserProviderMock.EXPECT().GetDetails(gomock.Any()).Times(0)
+ mock.UserProviderMock.EXPECT().GetDetailsExtended(gomock.Any()).Times(0)
},
ExpectDetails: nil,
},
@@ -142,11 +144,27 @@ func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) {
CCS: false,
Level: authentication.OneFactor,
Setup: func(mock *mocks.MockAutheliaCtx) {
+ mock.UserProviderMock.EXPECT().GetDetailsExtended(gomock.Any()).Times(0)
mock.UserProviderMock.EXPECT().
GetDetails(gomock.Eq("john")).
Return(&authentication.UserDetails{Username: "john"}, nil)
},
- ExpectDetails: &authentication.UserDetails{Username: "john"},
+ ExpectDetails: &authentication.UserDetailsExtended{UserDetails: &authentication.UserDetails{Username: "john"}},
+ },
+ {
+ Name: "ShouldResolveExtendedDetailsForUserBoundTokenWhenHeadersRequireThem",
+ Username: "john",
+ ClientID: "",
+ CCS: false,
+ Level: authentication.OneFactor,
+ Extended: true,
+ Setup: func(mock *mocks.MockAutheliaCtx) {
+ mock.UserProviderMock.EXPECT().GetDetails(gomock.Any()).Times(0)
+ mock.UserProviderMock.EXPECT().
+ GetDetailsExtended(gomock.Eq("john")).
+ Return(&authentication.UserDetailsExtended{GivenName: "John", UserDetails: &authentication.UserDetails{Username: "john"}}, nil)
+ },
+ ExpectDetails: &authentication.UserDetailsExtended{GivenName: "John", UserDetails: &authentication.UserDetails{Username: "john"}},
},
{
Name: "ShouldReturnErrorWhenGetDetailsFails",
@@ -185,7 +203,7 @@ func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) {
tc.Setup(mock)
}
- details, clientID, ccs, level, err := handleVerifyGETAuthorizationBearerResolveUser(mock.Ctx, tc.Username, tc.ClientID, tc.CCS, tc.Level)
+ details, clientID, ccs, level, err := handleVerifyGETAuthorizationBearerResolveUser(mock.Ctx, tc.Username, tc.ClientID, tc.CCS, tc.Level, tc.Extended)
if tc.ExpectError != "" {
require.EqualError(t, err, tc.ExpectError)
diff --git a/internal/handlers/handler_authz_test.go b/internal/handlers/handler_authz_test.go
index 3edc17b85..c060fe0fb 100644
--- a/internal/handlers/handler_authz_test.go
+++ b/internal/handlers/handler_authz_test.go
@@ -22,6 +22,7 @@ import (
"github.com/authelia/authelia/v4/internal/authentication"
"github.com/authelia/authelia/v4/internal/configuration/schema"
+ "github.com/authelia/authelia/v4/internal/expression"
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/mocks"
"github.com/authelia/authelia/v4/internal/model"
@@ -91,11 +92,11 @@ func (s *AuthzSuite) Builder() (builder *AuthzBuilder) {
switch s.implementation {
case AuthzImplExtAuthz:
- return NewAuthzBuilder().WithImplementationExtAuthz()
+ return NewAuthzBuilder().WithImplementationExtAuthz().WithEndpointHeaders(schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameExtAuthz].Headers)
case AuthzImplForwardAuth:
- return NewAuthzBuilder().WithImplementationForwardAuth()
+ return NewAuthzBuilder().WithImplementationForwardAuth().WithEndpointHeaders(schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].Headers)
case AuthzImplAuthRequest:
- return NewAuthzBuilder().WithImplementationAuthRequest()
+ return NewAuthzBuilder().WithImplementationAuthRequest().WithEndpointHeaders(schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameAuthRequest].Headers)
case AuthzImplLegacy:
return NewAuthzBuilder().WithImplementationLegacy()
}
@@ -1569,6 +1570,167 @@ func (s *AuthzSuite) TestShouldNotFailOnMissingEmail() {
s.Equal("abc,123", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteGroups)))
}
+func (s *AuthzSuite) TestShouldSetHeadersFromExtendedUserAttributes() {
+ if s.setRequest == nil || s.implementation == AuthzImplLegacy {
+ s.T().Skip()
+ }
+
+ mock := mocks.NewMockAutheliaCtx(s.T())
+
+ defer mock.Close()
+
+ setUpMockClock(mock)
+
+ authz := s.Builder().WithConfig(&mock.Ctx.Configuration).WithEndpointHeaders(map[string]schema.ServerEndpointsAuthzHeader{
+ schema.HeaderRemoteUser: {UserAttribute: expression.AttributeUserUsername},
+ "Remote-Given-Name": {UserAttribute: expression.AttributeUserGivenName},
+ "Remote-Teams": {UserAttribute: "teams"},
+ "Remote-Employee-Id": {UserAttribute: "employee_id"},
+ "Remote-Contractor": {UserAttribute: "contractor"},
+ "Remote-Unknown": {UserAttribute: "unknown"},
+ }).Build()
+
+ targetURI := s.RequireParseRequestURI("https://bypass.example.com")
+
+ s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false)
+
+ userSession, err := mock.Ctx.GetSession()
+ s.Require().NoError(err)
+
+ userSession.Username = testUsername
+ userSession.DisplayName = "John Smith"
+ userSession.Groups = []string{"admins", "dev"}
+ userSession.Emails = []string{"john@example.com"}
+ userSession.AuthenticationMethodRefs.UsernameAndPassword = true
+ userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute)
+
+ s.Require().NoError(mock.Ctx.SaveSession(userSession))
+
+ mock.UserProviderMock.EXPECT().
+ GetDetailsExtended(gomock.Eq(testUsername)).
+ Return(&authentication.UserDetailsExtended{
+ GivenName: "John",
+ UserDetails: &authentication.UserDetails{
+ Username: testUsername,
+ DisplayName: "John Smith",
+ Groups: []string{"admins", "dev"},
+ Emails: []string{"john@example.com"},
+ },
+ Extra: map[string]any{
+ "teams": []any{"devs", "ops"},
+ "employee_id": float64(10000000),
+ "contractor": false,
+ },
+ }, nil).Times(1)
+
+ authz.Handler(mock.Ctx)
+
+ s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode())
+ s.Equal(testUsername, string(mock.Ctx.Response.Header.PeekBytes(headerRemoteUser)))
+ s.Equal("John", string(mock.Ctx.Response.Header.Peek("Remote-Given-Name")))
+ s.Equal("devs,ops", string(mock.Ctx.Response.Header.Peek("Remote-Teams")))
+ s.Equal("10000000", string(mock.Ctx.Response.Header.Peek("Remote-Employee-Id")))
+ s.Equal("false", string(mock.Ctx.Response.Header.Peek("Remote-Contractor")))
+ s.Equal("", string(mock.Ctx.Response.Header.Peek("Remote-Unknown")))
+}
+
+func (s *AuthzSuite) TestShouldNotAuthenticateWhenExtendedUserDetailsAreEmpty() {
+ if s.setRequest == nil || s.implementation == AuthzImplLegacy {
+ s.T().Skip()
+ }
+
+ testCases := []struct {
+ name string
+ details *authentication.UserDetailsExtended
+ }{
+ {"ShouldHandleNil", nil},
+ {"ShouldHandleEmptyUsername", &authentication.UserDetailsExtended{UserDetails: &authentication.UserDetails{}}},
+ }
+
+ for _, tc := range testCases {
+ s.Run(tc.name, func() {
+ mock := mocks.NewMockAutheliaCtx(s.T())
+
+ defer mock.Close()
+
+ setUpMockClock(mock)
+
+ authz := s.Builder().WithConfig(&mock.Ctx.Configuration).WithEndpointHeaders(map[string]schema.ServerEndpointsAuthzHeader{
+ schema.HeaderRemoteUser: {UserAttribute: expression.AttributeUserUsername},
+ "Remote-Given-Name": {UserAttribute: expression.AttributeUserGivenName},
+ }).Build()
+
+ targetURI := s.RequireParseRequestURI("https://bypass.example.com")
+
+ s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false)
+
+ userSession, err := mock.Ctx.GetSession()
+ s.Require().NoError(err)
+
+ userSession.Username = testUsername
+ userSession.AuthenticationMethodRefs.UsernameAndPassword = true
+ userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute)
+
+ s.Require().NoError(mock.Ctx.SaveSession(userSession))
+
+ mock.UserProviderMock.EXPECT().
+ GetDetailsExtended(gomock.Eq(testUsername)).
+ Return(tc.details, nil).Times(1)
+
+ authz.Handler(mock.Ctx)
+
+ s.Equal("", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteUser)))
+ s.Equal("", string(mock.Ctx.Response.Header.Peek("Remote-Given-Name")))
+
+ userSession, err = mock.Ctx.GetSession()
+ s.Require().NoError(err)
+
+ s.Equal("", userSession.Username)
+ })
+ }
+}
+
+func (s *AuthzSuite) TestShouldNotRetrieveUserDetailsWhenHeadersOnlyRequireSessionAttributes() {
+ if s.setRequest == nil {
+ s.T().Skip()
+ }
+
+ mock := mocks.NewMockAutheliaCtx(s.T())
+
+ defer mock.Close()
+
+ setUpMockClock(mock)
+
+ authz := s.Builder().WithConfig(&mock.Ctx.Configuration).Build()
+
+ targetURI := s.RequireParseRequestURI("https://bypass.example.com")
+
+ s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false)
+
+ userSession, err := mock.Ctx.GetSession()
+ s.Require().NoError(err)
+
+ userSession.Username = testUsername
+ userSession.DisplayName = "John Smith"
+ userSession.Groups = []string{"admins", "dev"}
+ userSession.Emails = []string{"john@example.com"}
+ userSession.AuthenticationMethodRefs.UsernameAndPassword = true
+ userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute)
+
+ s.Require().NoError(mock.Ctx.SaveSession(userSession))
+
+ mock.UserProviderMock.EXPECT().GetDetails(gomock.Any()).Times(0)
+ mock.UserProviderMock.EXPECT().GetDetailsExtended(gomock.Any()).Times(0)
+
+ authz.Handler(mock.Ctx)
+
+ s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode())
+ s.Equal(testUsername, string(mock.Ctx.Response.Header.PeekBytes(headerRemoteUser)))
+ s.Equal("John Smith", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteName)))
+ s.Equal("admins,dev", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteGroups)))
+ s.Equal("john@example.com", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteEmail)))
+}
+
func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomain() {
if s.setRequest == nil {
s.T().Skip()
diff --git a/internal/handlers/handler_authz_types.go b/internal/handlers/handler_authz_types.go
index f2870c943..6d570c4a7 100644
--- a/internal/handlers/handler_authz_types.go
+++ b/internal/handlers/handler_authz_types.go
@@ -34,13 +34,21 @@ type Authz struct {
handleUnauthorized HandlerAuthzUnauthorized
implementation AuthzImplementation
+
+ headers []AuthzHeader
+}
+
+// AuthzHeader represents a response header which is set from a resolved user attribute.
+type AuthzHeader struct {
+ Key []byte
+ Attribute string
}
// HandlerAuthzUnauthorized is a Authz handler func that handles unauthorized responses.
type HandlerAuthzUnauthorized func(ctx AuthzContext, authn *Authn, redirectionURL *url.URL)
// HandlerAuthzAuthorized is a Authz handler func that handles authorized responses.
-type HandlerAuthzAuthorized func(ctx AuthzContext, authn *Authn)
+type HandlerAuthzAuthorized func(ctx AuthzContext, headers []AuthzHeader, authn *Authn)
// HandlerAuthzGetAutheliaURL is a Authz handler func that handles retrieval of the Portal URL.
type HandlerAuthzGetAutheliaURL func(ctx AuthzContext) (portalURL *url.URL, err error)
@@ -77,7 +85,7 @@ type Authn struct {
Method string
ClientID string
- Details authentication.UserDetails
+ Details *authentication.UserDetailsExtended
Level authentication.Level
Object authorization.Object
Type AuthnType
@@ -107,6 +115,7 @@ type AuthzBuilder struct {
config AuthzConfig
implementation AuthzImplementation
strategies []AuthnStrategy
+ headers map[string]schema.ServerEndpointsAuthzHeader
}
// AuthnStrategy is a strategy used for Authz authentication.
diff --git a/internal/handlers/handler_authz_util.go b/internal/handlers/handler_authz_util.go
index 27bf6ad92..307c53dc8 100644
--- a/internal/handlers/handler_authz_util.go
+++ b/internal/handlers/handler_authz_util.go
@@ -7,15 +7,71 @@ package handlers
import (
"fmt"
"net/url"
+ "strconv"
+ "strings"
+ "time"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/authentication"
"github.com/authelia/authelia/v4/internal/authorization"
+ "github.com/authelia/authelia/v4/internal/expression"
"github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/utils"
)
+var authzSessionUserAttributes = []string{
+ expression.AttributeUserUsername,
+ expression.AttributeUserGroups,
+ expression.AttributeUserDisplayName,
+ expression.AttributeUserEmail,
+ expression.AttributeUserEmails,
+ expression.AttributeUserEmailsExtra,
+ expression.AttributeUserEmailVerified,
+ expression.AttributeUserUpdatedAt,
+}
+
+func authzHeadersRequireExtendedUserDetails(headers []AuthzHeader) (extended bool) {
+ for _, header := range headers {
+ if !utils.IsStringInSlice(header.Attribute, authzSessionUserAttributes) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func authzHeaderValue(object any) (value string) {
+ switch v := object.(type) {
+ case nil:
+ return ""
+ case string:
+ return v
+ case []string:
+ return strings.Join(v, ",")
+ case []any:
+ values := make([]string, len(v))
+
+ for i, item := range v {
+ values[i] = authzHeaderValue(item)
+ }
+
+ return strings.Join(values, ",")
+ case bool:
+ return strconv.FormatBool(v)
+ case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
+ return fmt.Sprintf("%d", v)
+ case float32:
+ return strconv.FormatFloat(float64(v), 'f', -1, 32)
+ case float64:
+ return strconv.FormatFloat(v, 'f', -1, 64)
+ case time.Time:
+ return v.Format(time.RFC3339)
+ default:
+ return fmt.Sprintf("%v", v)
+ }
+}
+
func friendlyMethod(m string) (fm string) {
switch m {
case "":
diff --git a/internal/handlers/handler_authz_util_test.go b/internal/handlers/handler_authz_util_test.go
index 8ed9706cb..84ea6102b 100644
--- a/internal/handlers/handler_authz_util_test.go
+++ b/internal/handlers/handler_authz_util_test.go
@@ -7,12 +7,15 @@ package handlers
import (
"net/url"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/authorization"
+ "github.com/authelia/authelia/v4/internal/configuration/schema"
+ "github.com/authelia/authelia/v4/internal/expression"
"github.com/authelia/authelia/v4/internal/mocks"
)
@@ -372,3 +375,148 @@ func TestGetSafeAutheliaURL(t *testing.T) {
})
}
}
+
+func TestAuthzHeaderValue(t *testing.T) {
+ testCases := []struct {
+ Name string
+ Have any
+ Expected string
+ }{
+ {
+ Name: "ShouldHandleNil",
+ Have: nil,
+ Expected: "",
+ },
+ {
+ Name: "ShouldHandleString",
+ Have: "john",
+ Expected: "john",
+ },
+ {
+ Name: "ShouldJoinStringSlice",
+ Have: []string{"admins", "dev"},
+ Expected: "admins,dev",
+ },
+ {
+ Name: "ShouldJoinEmptyStringSlice",
+ Have: []string{},
+ Expected: "",
+ },
+ {
+ Name: "ShouldJoinAnySliceOfStrings",
+ Have: []any{"admins", "dev"},
+ Expected: "admins,dev",
+ },
+ {
+ Name: "ShouldJoinAnySliceOfMixedValues",
+ Have: []any{"admins", 5, true, nil, 1.5},
+ Expected: "admins,5,true,,1.5",
+ },
+ {
+ Name: "ShouldHandleBool",
+ Have: false,
+ Expected: "false",
+ },
+ {
+ Name: "ShouldHandleInt",
+ Have: 10000000,
+ Expected: "10000000",
+ },
+ {
+ Name: "ShouldHandleInt64",
+ Have: int64(-5),
+ Expected: "-5",
+ },
+ {
+ Name: "ShouldHandleUint",
+ Have: uint(5),
+ Expected: "5",
+ },
+ {
+ Name: "ShouldHandleFloat64WithoutScientificNotation",
+ Have: float64(10000000),
+ Expected: "10000000",
+ },
+ {
+ Name: "ShouldHandleFloat64WithFraction",
+ Have: 1.5,
+ Expected: "1.5",
+ },
+ {
+ Name: "ShouldHandleFloat32",
+ Have: float32(1.5),
+ Expected: "1.5",
+ },
+ {
+ Name: "ShouldHandleTime",
+ Have: time.Date(2026, 9, 12, 17, 0, 0, 0, time.UTC),
+ Expected: "2026-09-12T17:00:00Z",
+ },
+ {
+ Name: "ShouldFallbackToDefaultFormatting",
+ Have: map[string]string{"abc": "123"},
+ Expected: "map[abc:123]",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.Name, func(t *testing.T) {
+ assert.Equal(t, tc.Expected, authzHeaderValue(tc.Have))
+ })
+ }
+}
+
+func TestAuthzHeadersRequireExtendedUserDetails(t *testing.T) {
+ testCases := []struct {
+ Name string
+ Have []AuthzHeader
+ Expected bool
+ }{
+ {
+ Name: "ShouldNotRequireExtendedForNoHeaders",
+ Have: nil,
+ Expected: false,
+ },
+ {
+ Name: "ShouldNotRequireExtendedForDefaultHeaders",
+ Have: []AuthzHeader{
+ {Key: []byte(schema.HeaderRemoteUser), Attribute: expression.AttributeUserUsername},
+ {Key: []byte(schema.HeaderRemoteGroups), Attribute: expression.AttributeUserGroups},
+ {Key: []byte(schema.HeaderRemoteName), Attribute: expression.AttributeUserDisplayName},
+ {Key: []byte(schema.HeaderRemoteEmail), Attribute: expression.AttributeUserEmail},
+ },
+ Expected: false,
+ },
+ {
+ Name: "ShouldNotRequireExtendedForOtherSessionAttributes",
+ Have: []AuthzHeader{
+ {Key: []byte("Remote-Emails"), Attribute: expression.AttributeUserEmails},
+ {Key: []byte("Remote-Emails-Extra"), Attribute: expression.AttributeUserEmailsExtra},
+ {Key: []byte("Remote-Email-Verified"), Attribute: expression.AttributeUserEmailVerified},
+ {Key: []byte("Remote-Updated-At"), Attribute: expression.AttributeUserUpdatedAt},
+ },
+ Expected: false,
+ },
+ {
+ Name: "ShouldRequireExtendedForStandardExtendedAttribute",
+ Have: []AuthzHeader{
+ {Key: []byte(schema.HeaderRemoteUser), Attribute: expression.AttributeUserUsername},
+ {Key: []byte("Remote-Given-Name"), Attribute: expression.AttributeUserGivenName},
+ },
+ Expected: true,
+ },
+ {
+ Name: "ShouldRequireExtendedForCustomAttribute",
+ Have: []AuthzHeader{
+ {Key: []byte("Remote-Teams"), Attribute: "teams"},
+ },
+ Expected: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.Name, func(t *testing.T) {
+ assert.Equal(t, tc.Expected, authzHeadersRequireExtendedUserDetails(tc.Have))
+ })
+ }
+}
diff --git a/internal/middlewares/authelia_context.go b/internal/middlewares/authelia_context.go
index b5dc05886..0f386b718 100644
--- a/internal/middlewares/authelia_context.go
+++ b/internal/middlewares/authelia_context.go
@@ -647,7 +647,7 @@ func (ctx *AutheliaCtx) SpecialRedirect(uri string, statusCode int) {
u, statusCode = ctx.setSpecialRedirect(uri, statusCode)
ctx.SetContentTypeTextHTML()
- ctx.SetBodyString(fmt.Sprintf("%d %s", utils.StringHTMLEscape(string(u)), statusCode, fasthttp.StatusMessage(statusCode)))
+ ctx.SetBodyString(fmt.Sprintf(`%d %s`, utils.StringHTMLEscape(string(u)), statusCode, fasthttp.StatusMessage(statusCode)))
}
// SpecialRedirectNoBody performs a redirect similar to fasthttp.RequestCtx except it allows statusCode 401 and includes
diff --git a/internal/mocks/authelia_context.go b/internal/mocks/authelia_context.go
index c9a205390..ec02ddabe 100644
--- a/internal/mocks/authelia_context.go
+++ b/internal/mocks/authelia_context.go
@@ -23,6 +23,7 @@ import (
"github.com/authelia/authelia/v4/internal/authorization"
"github.com/authelia/authelia/v4/internal/clock"
"github.com/authelia/authelia/v4/internal/configuration/schema"
+ "github.com/authelia/authelia/v4/internal/expression"
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/random"
"github.com/authelia/authelia/v4/internal/regulation"
@@ -213,6 +214,8 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx {
providers.Authorizer = authorization.NewAuthorizer(
&config)
+ providers.UserAttributeResolver = expression.NewUserAttributes(&config)
+
providers.SessionProvider = session.NewProvider(config.Session, nil)
providers.Regulator = regulation.NewRegulator(config.Regulation, providers.StorageProvider, &mockAuthelia.Clock)
diff --git a/internal/suites/Standalone/configuration.yml b/internal/suites/Standalone/configuration.yml
index 15d19af04..7fb85f4a7 100644
--- a/internal/suites/Standalone/configuration.yml
+++ b/internal/suites/Standalone/configuration.yml
@@ -21,6 +21,28 @@ server:
- 'session'
- 'user'
cache: '0'
+ authz:
+ legacy:
+ implementation: 'Legacy'
+ auth-request:
+ implementation: 'AuthRequest'
+ ext-authz:
+ implementation: 'ExtAuthz'
+ forward-auth:
+ implementation: 'ForwardAuth'
+ forward-auth-attributes:
+ implementation: 'ForwardAuth'
+ headers:
+ Remote-User:
+ user_attribute: 'username'
+ Remote-Groups:
+ user_attribute: 'groups'
+ Remote-Given-Name:
+ user_attribute: 'given_name'
+ Remote-Employee-Id:
+ user_attribute: 'employee_id'
+ Remote-Is-Admin:
+ user_attribute: 'is_admin'
rate_limits:
reset_password_start:
buckets:
@@ -95,6 +117,14 @@ session:
authentication_backend:
file:
path: '/config/users.yml'
+ extra_attributes:
+ employee_id:
+ value_type: 'integer'
+
+definitions:
+ user_attributes:
+ is_admin:
+ expression: '"admins" in groups'
totp:
issuer: 'example.com'
diff --git a/internal/suites/Standalone/users.yml b/internal/suites/Standalone/users.yml
index 5b0bbd6e5..d83c1fef7 100644
--- a/internal/suites/Standalone/users.yml
+++ b/internal/suites/Standalone/users.yml
@@ -8,9 +8,12 @@ users:
displayname: "John Doe"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: john.doe@authelia.com
+ given_name: "John"
groups:
- admins
- dev
+ extra:
+ employee_id: 1001
harry:
displayname: "Harry Potter"
diff --git a/internal/suites/suite_standalone_test.go b/internal/suites/suite_standalone_test.go
index 0033fb820..561067ec5 100644
--- a/internal/suites/suite_standalone_test.go
+++ b/internal/suites/suite_standalone_test.go
@@ -12,6 +12,7 @@ import (
"log"
"net/http"
"net/url"
+ "strings"
"testing"
"time"
@@ -296,6 +297,82 @@ func (s *StandaloneSuite) TestShouldVerifyAPIVerifyRedirectFromXOriginalHostURI(
s.Assert().Equal(fmt.Sprintf("302 Found", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s&rm=GET", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body))
}
+func (s *StandaloneSuite) doAuthzBasicRequest(endpoint, username, password string) *http.Response {
+ req, err := http.NewRequest(fasthttp.MethodGet, fmt.Sprintf("%s/api/authz/%s", AutheliaBaseURL, endpoint), nil)
+ s.Require().NoError(err)
+
+ req.Header.Set("X-Forwarded-Method", fasthttp.MethodGet)
+ req.Header.Set(fasthttp.HeaderXForwardedProto, "https")
+ req.Header.Set(fasthttp.HeaderXForwardedHost, fmt.Sprintf("singlefactor.%s", BaseDomain))
+ req.Header.Set("X-Forwarded-URI", "/")
+ req.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf8")
+
+ if username != "" {
+ req.SetBasicAuth(username, password)
+ }
+
+ res, err := NewHTTPClient().Do(req)
+ s.Require().NoError(err)
+
+ s.T().Cleanup(func() {
+ _ = res.Body.Close()
+ })
+
+ return res
+}
+
+func (s *StandaloneSuite) TestShouldRespondWithDefaultAuthzHeaders() {
+ res := s.doAuthzBasicRequest("forward-auth", "john", "password")
+
+ s.Require().Equal(fasthttp.StatusOK, res.StatusCode)
+
+ s.Assert().Equal("john", res.Header.Get("Remote-User"))
+ s.Assert().ElementsMatch([]string{"admins", "dev"}, strings.Split(res.Header.Get("Remote-Groups"), ","))
+ s.Assert().Equal("John Doe", res.Header.Get("Remote-Name"))
+ s.Assert().Equal("john.doe@authelia.com", res.Header.Get("Remote-Email"))
+
+ s.Assert().Empty(res.Header.Values("Remote-Given-Name"))
+ s.Assert().Empty(res.Header.Values("Remote-Employee-Id"))
+ s.Assert().Empty(res.Header.Values("Remote-Is-Admin"))
+}
+
+func (s *StandaloneSuite) TestShouldRespondWithConfiguredAuthzHeaders() {
+ res := s.doAuthzBasicRequest("forward-auth-attributes", "john", "password")
+
+ s.Require().Equal(fasthttp.StatusOK, res.StatusCode)
+
+ s.Assert().Equal("john", res.Header.Get("Remote-User"))
+ s.Assert().ElementsMatch([]string{"admins", "dev"}, strings.Split(res.Header.Get("Remote-Groups"), ","))
+ s.Assert().Equal("John", res.Header.Get("Remote-Given-Name"))
+ s.Assert().Equal("1001", res.Header.Get("Remote-Employee-Id"))
+ s.Assert().Equal("true", res.Header.Get("Remote-Is-Admin"))
+
+ s.Assert().Empty(res.Header.Values("Remote-Name"))
+ s.Assert().Empty(res.Header.Values("Remote-Email"))
+}
+
+func (s *StandaloneSuite) TestShouldRespondWithConfiguredAuthzHeadersForUserWithoutAttributes() {
+ res := s.doAuthzBasicRequest("forward-auth-attributes", "harry", "password")
+
+ s.Require().Equal(fasthttp.StatusOK, res.StatusCode)
+
+ s.Assert().Equal("harry", res.Header.Get("Remote-User"))
+ s.Assert().Equal("", res.Header.Get("Remote-Groups"))
+ s.Assert().Equal("", res.Header.Get("Remote-Given-Name"))
+ s.Assert().Equal("", res.Header.Get("Remote-Employee-Id"))
+ s.Assert().Equal("false", res.Header.Get("Remote-Is-Admin"))
+}
+
+func (s *StandaloneSuite) TestShouldNotRespondWithConfiguredAuthzHeadersWhenUnauthenticated() {
+ res := s.doAuthzBasicRequest("forward-auth-attributes", "", "")
+
+ s.Assert().Equal(fasthttp.StatusFound, res.StatusCode)
+
+ for _, header := range []string{"Remote-User", "Remote-Groups", "Remote-Given-Name", "Remote-Employee-Id", "Remote-Is-Admin"} {
+ s.Assert().Empty(res.Header.Values(header), header)
+ }
+}
+
func (s *StandaloneSuite) TestShouldServeVerboseHealthCheck() {
client := NewHTTPClient()