feat(handlers): derive authz response headers from user attributes (#9346)

Each authz endpoint takes a headers option mapping a response header
name to a user attribute, so any standard, extended, or custom attribute
can be sent to the proxy. Remote-User, Remote-Groups, Remote-Name and
Remote-Email remain the default for every implementation except legacy.

Extended user details are only retrieved when a configured attribute
requires them, which is determined when the endpoint is built. Header
names and the attributes they resolve are validated at startup.

---------

Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
Co-authored-by: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com>
This commit is contained in:
James ElliottandAmir Zarrinkafsh authored and GitHub committed 2026-09-27 22:51:40 +00:00
1 parent 177cb9e3ea
commit 3837cffa88
25 files changed
+1298 -76

No files matched your search

+3
View File
@@ -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'
@@ -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" >}}
+145
View File
@@ -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
}
@@ -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'
+14
View File
@@ -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"
+3
View File
@@ -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",
+49
View File
@@ -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,
+71 -26
View File
@@ -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,
+98 -2
View File
@@ -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
@@ -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)
}
})
}
}
+5 -4
View File
@@ -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 (
+5 -5
View File
@@ -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
}
+79 -22
View File
@@ -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")
}
@@ -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
+28 -7
View File
@@ -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])
}
}
}
+22 -4
View File
@@ -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)
+165 -3
View File
@@ -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()
+11 -2
View File
@@ -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.
+56
View File
@@ -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 "":
@@ -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))
})
}
}
+1 -1
View File
@@ -647,7 +647,7 @@ func (ctx *AutheliaCtx) SpecialRedirect(uri string, statusCode int) {
u, statusCode = ctx.setSpecialRedirect(uri, statusCode)
ctx.SetContentTypeTextHTML()
ctx.SetBodyString(fmt.Sprintf("<a href=\"%s\">%d %s</a>", utils.StringHTMLEscape(string(u)), statusCode, fasthttp.StatusMessage(statusCode)))
ctx.SetBodyString(fmt.Sprintf(`<a href="%s">%d %s</a>`, utils.StringHTMLEscape(string(u)), statusCode, fasthttp.StatusMessage(statusCode)))
}
// SpecialRedirectNoBody performs a redirect similar to fasthttp.RequestCtx except it allows statusCode 401 and includes
+3
View File
@@ -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)
@@ -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'
+3
View File
@@ -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"
+77
View File
@@ -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("<a href=\"%s\">302 Found</a>", 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()