diff options
| author | James Elliott <james-d-elliott@users.noreply.github.com> | 2025-03-01 12:41:53 +1100 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-01 12:41:53 +1100 | 
| commit | eadf0ba3010a7d0648a30f93c146a8f21694d999 (patch) | |
| tree | e562d9dcb55770bdc56ed7600bb6005653b8d13f | |
| parent | f62b6cd853d378e0f0a11df5fec68020da16baf2 (diff) | |
feat(oidc): merged id token claims (#8851)
This introduces a feature to the claims policy that allows merging the granted audience into the ID Token. This is not traditionally spec compliant but has some specific use cases.
Closes #8619
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
| -rw-r--r-- | cmd/authelia-scripts/cmd/gen.go | 2 | ||||
| -rw-r--r-- | docs/content/configuration/identity-providers/openid-connect/provider.md | 20 | ||||
| -rw-r--r-- | docs/content/integration/openid-connect/introduction.md | 13 | ||||
| -rw-r--r-- | docs/data/languages.json | 92 | ||||
| -rw-r--r-- | docs/data/misc.json | 4 | ||||
| -rw-r--r-- | docs/static/schemas/v4.39/json-schema/configuration.json | 15 | ||||
| -rw-r--r-- | internal/configuration/schema/identity_providers.go | 2 | ||||
| -rw-r--r-- | internal/configuration/schema/keys.go | 1 | ||||
| -rw-r--r-- | internal/configuration/validator/identity_providers.go | 45 | ||||
| -rw-r--r-- | internal/configuration/validator/identity_providers_test.go | 536 | ||||
| -rw-r--r-- | internal/handlers/handler_oauth_device_authorization.go | 4 | ||||
| -rw-r--r-- | internal/handlers/handler_oidc_authorization.go | 4 | ||||
| -rw-r--r-- | internal/oidc/claims.go | 11 | ||||
| -rw-r--r-- | internal/oidc/const.go | 27 | ||||
| -rw-r--r-- | web/src/i18n/index.ts | 2 | 
15 files changed, 738 insertions, 40 deletions
diff --git a/cmd/authelia-scripts/cmd/gen.go b/cmd/authelia-scripts/cmd/gen.go index f0ea9c216..0a4251277 100644 --- a/cmd/authelia-scripts/cmd/gen.go +++ b/cmd/authelia-scripts/cmd/gen.go @@ -7,5 +7,5 @@  package cmd  const ( -	versionSwaggerUI = "5.19.0" +	versionSwaggerUI = "5.20.0"  ) diff --git a/docs/content/configuration/identity-providers/openid-connect/provider.md b/docs/content/configuration/identity-providers/openid-connect/provider.md index 81bed42a0..6ab20300d 100644 --- a/docs/content/configuration/identity-providers/openid-connect/provider.md +++ b/docs/content/configuration/identity-providers/openid-connect/provider.md @@ -581,6 +581,26 @@ The keys under `claims_policies` is an arbitrary value that can be used in the  The list of claims automatically copied to the ID Token in addition to the standard ID Token claims provided the  relevant scope was granted. +#### id_token_audience_mode + +{{< confkey type="string" default="specification" required="no" >}} + +The ID Token audience derivation mode for clients using this claims policy. It's recommended this is not configured +as the default mode is the correct mode in almost all situations, and if are considering changing this first read +the section on audiences in the [Integration Guide](../../../integration/openid-connect/introduction.md#audiences), +as there may be unintended security issues caused for relying parties that trust Authelia as a provider if you're not +cautious. + +The following table describes all of the modes. Please note that any mode value prefixed with `experimental-` may be +removed or renamed without notice, and it's suggested if you're using these modes that you start a +[Discussion](https://github.com/authelia/authelia/discussions/new?category=show-and-tell) showcasing how you're using +a specific mode so we can adequately gauge its overall value. + +|         Value         |                                                   Description                                                   | +|:---------------------:|:---------------------------------------------------------------------------------------------------------------:| +|    `specification`    |           This is the specification compliant mode where only the client id is recorded in the claim.           | +| `experimental-merged` | This mode includes the same value as `specification` but also merges the granted audience from the Access Token | +  #### access_token  {{< confkey type="list(string)" required="no" >}} diff --git a/docs/content/integration/openid-connect/introduction.md b/docs/content/integration/openid-connect/introduction.md index 6543990e9..d9ee268ce 100644 --- a/docs/content/integration/openid-connect/introduction.md +++ b/docs/content/integration/openid-connect/introduction.md @@ -44,10 +44,15 @@ determine what audiences these tokens are meant for. It should also be noted tha  should effectively never change this also applies to the audience of this token.  For these reasons the audience of the [Access Token], [Refresh Token], and [ID Token] are effectively completely -separate and Authelia treats them in this manner. An [ID Token] will always and only have the client identifier of the -specific client that requested it per specification, the [Access Token] will always have the granted audience of the -Authorization Flow or last successful Refresh Flow, and the [Refresh Token] will always have the granted audience of -the Authorization Flow. +separate and Authelia treats them in this manner. An [ID Token] will always and by default only have the client +identifier of the specific client that requested it and will lack the audiences granted to the [Access Token] as per the +specification, the [Access Token] will always have the granted audience of the Authorization Flow or last successful +Refresh Flow, and the [Refresh Token] will always have the granted audience of the Authorization Flow. + +You may adjust the derivation of the [ID Token] audience by configuring a +[claims policy](../../configuration/identity-providers/openid-connect/provider.md#claims_policies) and changing the +[id_token_audience_mode](../../configuration/identity-providers/openid-connect/provider.md#id_token_audience_mode) +option.  For more information about the opaque [Access Token] default see  [Why isn't the Access Token a JSON Web Token? (Frequently Asked Questions)](./frequently-asked-questions.md#why-isnt-the-access-token-a-json-web-token). diff --git a/docs/data/languages.json b/docs/data/languages.json index e5bc05e1c..38bfc4f8b 100644 --- a/docs/data/languages.json +++ b/docs/data/languages.json @@ -7,9 +7,9 @@          "namespace": "portal"      },      "namespaces": [ +        "consent",          "portal", -        "settings", -        "consent" +        "settings"      ],      "languages": [          { @@ -28,6 +28,7 @@              "display": "Afrikaans",              "locale": "af",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -39,6 +40,7 @@              "display": "Afrikaans (South Africa)",              "locale": "af-ZA",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -51,6 +53,7 @@              "display": "Arabic",              "locale": "ar",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -62,6 +65,7 @@              "display": "Arabic (Saudi Arabia)",              "locale": "ar-SA",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -74,6 +78,7 @@              "display": "Bulgarian",              "locale": "bg",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -85,6 +90,7 @@              "display": "Czech",              "locale": "cs",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -96,6 +102,7 @@              "display": "Czech (Czechia)",              "locale": "cs-CZ",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -108,6 +115,7 @@              "display": "Welsh",              "locale": "cy",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -119,6 +127,7 @@              "display": "Welsh (United Kingdom)",              "locale": "cy-GB",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -131,6 +140,7 @@              "display": "Danish",              "locale": "da",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -142,6 +152,7 @@              "display": "Danish (Denmark)",              "locale": "da-DK",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -154,6 +165,7 @@              "display": "German",              "locale": "de",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -165,6 +177,7 @@              "display": "Greek",              "locale": "el",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -176,6 +189,7 @@              "display": "Greek (Greece)",              "locale": "el-GR",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -188,6 +202,7 @@              "display": "Spanish",              "locale": "es",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -199,6 +214,7 @@              "display": "Estonian",              "locale": "et",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -210,6 +226,7 @@              "display": "Estonian (Estonia)",              "locale": "et-EE",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -222,6 +239,7 @@              "display": "Basque",              "locale": "eu",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -233,6 +251,7 @@              "display": "Basque (Spain)",              "locale": "eu-ES",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -245,6 +264,7 @@              "display": "Finnish",              "locale": "fi",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -256,6 +276,7 @@              "display": "Filipino",              "locale": "fil",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -267,6 +288,7 @@              "display": "Filipino (Philippines)",              "locale": "fil-PH",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -279,6 +301,7 @@              "display": "French",              "locale": "fr",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -290,6 +313,7 @@              "display": "Irish",              "locale": "ga",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -301,6 +325,7 @@              "display": "Irish (Ireland)",              "locale": "ga-IE",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -313,6 +338,7 @@              "display": "Hindi",              "locale": "hi",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -324,6 +350,7 @@              "display": "Hindi (India)",              "locale": "hi-IN",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -336,6 +363,7 @@              "display": "Croatian",              "locale": "hr",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -347,6 +375,7 @@              "display": "Hungarian",              "locale": "hu",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -358,6 +387,7 @@              "display": "Indonesian",              "locale": "id",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -369,6 +399,7 @@              "display": "Italian",              "locale": "it",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -380,6 +411,7 @@              "display": "Japanese",              "locale": "ja",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -391,6 +423,7 @@              "display": "Japanese (Japan)",              "locale": "ja-JP",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -403,6 +436,7 @@              "display": "Korean",              "locale": "ko",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -414,6 +448,7 @@              "display": "Korean (South Korea)",              "locale": "ko-KR",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -426,6 +461,7 @@              "display": "Kurdish",              "locale": "ku",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -437,6 +473,7 @@              "display": "Kurdish (Turkey)",              "locale": "ku-TR",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -449,6 +486,7 @@              "display": "Lithuanian",              "locale": "lt",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -460,6 +498,7 @@              "display": "Latvian",              "locale": "lv",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -471,6 +510,7 @@              "display": "Malay",              "locale": "ms",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -482,6 +522,7 @@              "display": "Malay (Malaysia)",              "locale": "ms-MY",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -494,6 +535,7 @@              "display": "Maltese",              "locale": "mt",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -505,6 +547,7 @@              "display": "Norwegian Bokmål",              "locale": "nb",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -516,6 +559,7 @@              "display": "Norwegian Bokmål (Norway)",              "locale": "nb-NO",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -528,6 +572,7 @@              "display": "Nepali",              "locale": "ne",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -539,6 +584,7 @@              "display": "Nepali (Nepal)",              "locale": "ne-NP",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -551,6 +597,7 @@              "display": "Dutch",              "locale": "nl",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -562,6 +609,7 @@              "display": "Norwegian Bokmål",              "locale": "no",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -573,6 +621,7 @@              "display": "Polish",              "locale": "pl",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -584,6 +633,7 @@              "display": "Portuguese",              "locale": "pt",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -595,6 +645,7 @@              "display": "Brazilian Portuguese",              "locale": "pt-BR",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -606,6 +657,7 @@              "display": "Romanian",              "locale": "ro",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -617,6 +669,7 @@              "display": "Russian",              "locale": "ru",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -628,6 +681,7 @@              "display": "Sardinian",              "locale": "sc",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -639,6 +693,7 @@              "display": "Sardinian (Italy)",              "locale": "sc-IT",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -651,6 +706,7 @@              "display": "Slovak",              "locale": "sk",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -662,6 +718,7 @@              "display": "Slovenian",              "locale": "sl",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -673,6 +730,7 @@              "display": "Slovenian (Slovenia)",              "locale": "sl-SI",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -685,6 +743,7 @@              "display": "Somali",              "locale": "so",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -696,6 +755,7 @@              "display": "Serbian",              "locale": "srp",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -707,6 +767,7 @@              "display": "Swati",              "locale": "ss",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -718,6 +779,7 @@              "display": "Swati (South Africa)",              "locale": "ss-ZA",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -730,6 +792,7 @@              "display": "Sundanese",              "locale": "su",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -741,6 +804,7 @@              "display": "Sundanese (Indonesia)",              "locale": "su-ID",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -753,6 +817,7 @@              "display": "Swedish",              "locale": "sv",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -764,6 +829,7 @@              "display": "Swedish (Sweden)",              "locale": "sv-SE",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -776,6 +842,7 @@              "display": "Swahili",              "locale": "sw",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -787,6 +854,7 @@              "display": "Swahili (Kenya)",              "locale": "sw-KE",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -799,6 +867,7 @@              "display": "Tamil",              "locale": "ta",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -810,6 +879,7 @@              "display": "Tamil (India)",              "locale": "ta-IN",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -822,6 +892,7 @@              "display": "Thai",              "locale": "th",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -833,6 +904,7 @@              "display": "Filipino (Philippines)",              "locale": "tl-PH",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -844,6 +916,7 @@              "display": "Turkish",              "locale": "tr",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -855,6 +928,7 @@              "display": "Tahitian",              "locale": "ty",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -866,6 +940,7 @@              "display": "Tahitian (French Polynesia)",              "locale": "ty-PF",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -878,6 +953,7 @@              "display": "Ukrainian",              "locale": "uk",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -889,6 +965,7 @@              "display": "Ukrainian (Ukraine)",              "locale": "uk-UA",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -901,6 +978,7 @@              "display": "Venetian",              "locale": "vec",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -912,6 +990,7 @@              "display": "Venetian (Italy)",              "locale": "vec-IT",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -924,6 +1003,7 @@              "display": "Vietnamese",              "locale": "vi",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -935,6 +1015,7 @@              "display": "Vietnamese (Vietnam)",              "locale": "vi-VN",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -947,6 +1028,7 @@              "display": "Yiddish",              "locale": "yi",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -958,6 +1040,7 @@              "display": "Yiddish (Germany)",              "locale": "yi-DE",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -970,6 +1053,7 @@              "display": "Chinese",              "locale": "zh",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -981,6 +1065,7 @@              "display": "Chinese (China)",              "locale": "zh-CN",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -993,6 +1078,7 @@              "display": "Chinese (Hong Kong SAR China)",              "locale": "zh-HK",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -1004,6 +1090,7 @@              "display": "Chinese (Singapore)",              "locale": "zh-SG",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], @@ -1015,6 +1102,7 @@              "display": "Chinese (Taiwan)",              "locale": "zh-TW",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], diff --git a/docs/data/misc.json b/docs/data/misc.json index 84c793105..14dd8dc56 100644 --- a/docs/data/misc.json +++ b/docs/data/misc.json @@ -7,8 +7,8 @@      "latest": "4.38.19",      "support": {          "traefik": [ -            "v3.3.3", -            "v2.11.20" +            "v3.3.4", +            "v2.11.21"          ]      }  } diff --git a/docs/static/schemas/v4.39/json-schema/configuration.json b/docs/static/schemas/v4.39/json-schema/configuration.json index 0b588560d..1cf4d26c3 100644 --- a/docs/static/schemas/v4.39/json-schema/configuration.json +++ b/docs/static/schemas/v4.39/json-schema/configuration.json @@ -1038,7 +1038,8 @@              "auto",              "light",              "dark", -            "grey" +            "grey", +            "oled"            ],            "title": "Theme Name",            "description": "The name of the theme to apply to the web UI.", @@ -1444,6 +1445,16 @@            "title": "Access Token",            "description": "The list of claims to automatically apply to an Access Token in addition to the specified Access Token Claims."          }, +        "id_token_audience_mode": { +          "type": "string", +          "enum": [ +            "specification", +            "experimental-merged" +          ], +          "title": "ID Token Audience Mode", +          "description": "Sets the mode for ID Token audience derivation for clients that use this policy.", +          "default": "specification" +        },          "custom_claims": {            "patternProperties": {              ".*": { @@ -4306,4 +4317,4 @@        "pattern": "^(-{5}BEGIN CERTIFICATE-{5}\\n([a-zA-Z0-9\\/+]{1,64}\\n)+([a-zA-Z0-9\\/+]{1,64}[=]{0,2})\\n-{5}END CERTIFICATE-{5}\\n?)+$"      }    } -}
\ No newline at end of file +} diff --git a/internal/configuration/schema/identity_providers.go b/internal/configuration/schema/identity_providers.go index 84382c8ef..cd719868e 100644 --- a/internal/configuration/schema/identity_providers.go +++ b/internal/configuration/schema/identity_providers.go @@ -49,6 +49,8 @@ type IdentityProvidersOpenIDConnectClaimsPolicy struct {  	IDToken     []string `koanf:"id_token" json:"id_token" jsonschema:"title=ID Token" jsonschema_description:"The list of claims to automatically apply to an ID Token in addition to the specified ID Token Claims."`  	AccessToken []string `koanf:"access_token" json:"access_token" jsonschema:"title=Access Token" jsonschema_description:"The list of claims to automatically apply to an Access Token in addition to the specified Access Token Claims."` +	IDTokenAudienceMode string `koanf:"id_token_audience_mode" json:"id_token_audience_mode" jsonschema:"default=specification,title=ID Token Audience Mode,enum=specification,enum=experimental-merged" jsonschema_description:"Sets the mode for ID Token audience derivation for clients that use this policy."` +  	CustomClaims map[string]IdentityProvidersOpenIDConnectCustomClaim `koanf:"custom_claims" json:"custom_claims" jsonschema:"title=Custom Claims" jsonschema_description:"The custom claims available in this policy in addition to the Standard Claims."`  } diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index 5c38f093c..f6ec52ac3 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -142,6 +142,7 @@ var Keys = []string{  	"identity_providers.oidc.claims_policies.*.custom_claims.*",  	"identity_providers.oidc.claims_policies.*.custom_claims.*.attribute",  	"identity_providers.oidc.claims_policies.*.id_token", +	"identity_providers.oidc.claims_policies.*.id_token_audience_mode",  	"identity_providers.oidc.clients",  	"identity_providers.oidc.clients[]",  	"identity_providers.oidc.clients[].access_token_encrypted_response_alg", diff --git a/internal/configuration/validator/identity_providers.go b/internal/configuration/validator/identity_providers.go index 39115ecc2..3fdc7cf8e 100644 --- a/internal/configuration/validator/identity_providers.go +++ b/internal/configuration/validator/identity_providers.go @@ -137,11 +137,18 @@ func validateOIDCLifespans(config *schema.Configuration, _ *schema.StructValidat  	}  } +//nolint:gocyclo  func validateOIDCClaims(config *schema.Configuration, validator *schema.StructValidator) {  	for name, policy := range config.IdentityProviders.OIDC.ClaimsPolicies { +		var claims []string +  		for claim, properties := range policy.CustomClaims {  			if utils.IsStringInSlice(claim, validOIDCReservedClaims) { -				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: claim with name '%s' is specifically reserved and cannot be customized", name, claim)) +				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: custom_claims: claim with name '%s' can't be used in a claims policy as it's a standard claim", name, claim)) +			} + +			if !utils.IsStringInSlice(claim, claims) { +				claims = append(claims, claim)  			}  			if !utils.IsStringInSlice(claim, config.IdentityProviders.OIDC.Discovery.Claims) { @@ -151,40 +158,48 @@ func validateOIDCClaims(config *schema.Configuration, validator *schema.StructVa  			if properties.Attribute == "" {  				properties.Attribute = claim  				policy.CustomClaims[claim] = properties -				config.IdentityProviders.OIDC.ClaimsPolicies[name] = policy  			}  			if !isUserAttributeValid(properties.Attribute, config) { -				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: claim with name '%s' has an attribute name '%s' which is unknown", name, claim, properties.Attribute)) +				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: claim with name '%s' has an attribute name '%s' which is not a known attribute", name, claim, properties.Attribute))  			}  		}  		for _, claim := range policy.IDToken {  			if utils.IsStringInSlice(claim, validOIDCReservedClaims) { -				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: id_token: claim with name '%s' is specifically reserved and cannot be customized", name, claim)) -			} else if !utils.IsStringInSlice(claim, config.IdentityProviders.OIDC.Discovery.Claims) && !utils.IsStringInSlice(claim, validOIDCClientClaims) { +				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: id_token: claim with name '%s' can't be used in a claims policy as it's a standard claim", name, claim)) +			} else if !utils.IsStringInSlice(claim, claims) && !utils.IsStringInSlice(claim, validOIDCClientClaims) {  				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: id_token: claim with name '%s' is not known", name, claim))  			}  		} +		switch policy.IDTokenAudienceMode { +		case "": +			policy.IDTokenAudienceMode = oidc.IDTokenAudienceModeSpecification +		case oidc.IDTokenAudienceModeSpecification, oidc.IDTokenAudienceModeExperimentalMerged: +			break +		default: +			validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: option 'id_token_audience_mode' must be one of %s but it's configured as '%s'", name, utils.StringJoinOr([]string{oidc.IDTokenAudienceModeSpecification, oidc.IDTokenAudienceModeExperimentalMerged}), policy.IDTokenAudienceMode)) +		} +  		for _, claim := range policy.AccessToken {  			if utils.IsStringInSlice(claim, validOIDCReservedClaims) { -				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: access_token: claim with name '%s' is specifically reserved and cannot be customized", name, claim)) -			} - -			if !utils.IsStringInSlice(claim, config.IdentityProviders.OIDC.Discovery.Claims) && !utils.IsStringInSlice(claim, validOIDCClientClaims) { +				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: access_token: claim with name '%s' can't be used in a claims policy as it's a standard claim", name, claim)) +			} else if !utils.IsStringInSlice(claim, claims) && !utils.IsStringInSlice(claim, validOIDCClientClaims) {  				validator.Push(fmt.Errorf("identity_providers: oidc: claims_policies: %s: access_token: claim with name '%s' is not known", name, claim))  			}  		} + +		config.IdentityProviders.OIDC.ClaimsPolicies[name] = policy  	}  }  func validateOIDCScopes(config *schema.Configuration, validator *schema.StructValidator) {  	for scope, properties := range config.IdentityProviders.OIDC.Scopes {  		if utils.IsStringInSlice(scope, validOIDCClientScopes) { -			validator.Push(fmt.Errorf("identity_providers: oidc: scopes: scope with name '%s' can't be used as it's a reserved scope", scope)) +			validator.Push(fmt.Errorf("identity_providers: oidc: scopes: scope with name '%s' can't be used as a custom scope because it's a standard scope", scope))  		} else if strings.HasPrefix(scope, "authelia.") { -			validator.Push(fmt.Errorf("identity_providers: oidc: scopes: scope with name '%s' can't be used as all scopes prefixed with 'authelia.' are reserved", scope)) +			validator.Push(fmt.Errorf("identity_providers: oidc: scopes: scope with name '%s' can't be used as a custom scope because all scopes prefixed with 'authelia.' are reserved", scope))  		}  		if !utils.IsStringInSlice(scope, config.IdentityProviders.OIDC.Discovery.Scopes) { @@ -193,11 +208,9 @@ func validateOIDCScopes(config *schema.Configuration, validator *schema.StructVa  		for _, claim := range properties.Claims {  			if utils.IsStringInSlice(claim, validOIDCReservedClaims) { -				validator.Push(fmt.Errorf("identity_providers: oidc: scopes: %s: claim with name '%s' is specifically reserved and cannot be customized", scope, claim)) -			} - -			if !utils.IsStringInSlice(claim, config.IdentityProviders.OIDC.Discovery.Claims) && !utils.IsStringInSlice(claim, validOIDCClientClaims) { -				validator.Push(fmt.Errorf("identity_providers: oidc: scopes: %s: claim with name '%s' is unknown", scope, claim)) +				validator.Push(fmt.Errorf("identity_providers: oidc: scopes: %s: claim with name '%s' can't be used in a custom scope as it's a standard claim", scope, claim)) +			} else if !utils.IsStringInSlice(claim, config.IdentityProviders.OIDC.Discovery.Claims) && !utils.IsStringInSlice(claim, validOIDCClientClaims) { +				validator.Push(fmt.Errorf("identity_providers: oidc: scopes: %s: claim with name '%s' is not a known claim", scope, claim))  			}  		}  	} diff --git a/internal/configuration/validator/identity_providers_test.go b/internal/configuration/validator/identity_providers_test.go index 39203aa77..2830b68b0 100644 --- a/internal/configuration/validator/identity_providers_test.go +++ b/internal/configuration/validator/identity_providers_test.go @@ -4112,7 +4112,7 @@ func TestValidateOIDCAuthorizationPolicies(t *testing.T) {  			errs := validator.Errors()  			sort.Sort(utils.ErrSliceSortAlphabetical(errs)) -			require.Len(t, validator.Errors(), len(tc.errors)) +			require.Len(t, errs, len(tc.errors))  			for i, err := range tc.errors {  				t.Run(fmt.Sprintf("Error%d", i+1), func(t *testing.T) { @@ -4127,6 +4127,540 @@ func TestValidateOIDCAuthorizationPolicies(t *testing.T) {  	}  } +func TestShouldValidateOpenIDConnectClaimsPolicies(t *testing.T) { +	testCases := []struct { +		name    string +		have    *schema.Configuration +		expectf func(t *testing.T, actual *schema.IdentityProvidersOpenIDConnect) +		errors  []string +	}{ +		{ +			name: "ShouldValidateWithoutPolicies", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{File: &schema.AuthenticationBackendFile{}}, +				IdentityProviders:     schema.IdentityProviders{OIDC: &schema.IdentityProvidersOpenIDConnect{}}, +			}, +		}, +		{ +			name: "ShouldSetDefaultIDTokenAudienceMode", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					File: &schema.AuthenticationBackendFile{}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								IDTokenAudienceMode: "", +							}, +							"example-spec": { +								IDTokenAudienceMode: "specification", +							}, +							"example-merged": { +								IDTokenAudienceMode: "experimental-merged", +							}, +						}, +					}, +				}, +			}, +			expectf: func(t *testing.T, actual *schema.IdentityProvidersOpenIDConnect) { +				assert.Equal(t, "specification", actual.ClaimsPolicies["example"].IDTokenAudienceMode) +				assert.Equal(t, "specification", actual.ClaimsPolicies["example-spec"].IDTokenAudienceMode) +				assert.Equal(t, "experimental-merged", actual.ClaimsPolicies["example-merged"].IDTokenAudienceMode) +			}, +		}, +		{ +			name: "ShouldErrorOnInvalidIDTokenAudienceMode", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					File: &schema.AuthenticationBackendFile{}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								IDTokenAudienceMode: "invalid", +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: claims_policies: example: option 'id_token_audience_mode' must be one of 'specification' or 'experimental-merged' but it's configured as 'invalid'", +			}, +		}, +		{ +			name: "ShouldNotAllowReservedOrNonMetaClaims", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					File: &schema.AuthenticationBackendFile{}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								IDToken:     []string{"no-id-claim", "sub"}, +								AccessToken: []string{"no-at-claim", "sub"}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: claims_policies: example: access_token: claim with name 'no-at-claim' is not known", +				"identity_providers: oidc: claims_policies: example: access_token: claim with name 'sub' can't be used in a claims policy as it's a standard claim", +				"identity_providers: oidc: claims_policies: example: id_token: claim with name 'no-id-claim' is not known", +				"identity_providers: oidc: claims_policies: example: id_token: claim with name 'sub' can't be used in a claims policy as it's a standard claim", +			}, +		}, +		{ +			name: "ShouldAllowCustomClaimsManual", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					File: &schema.AuthenticationBackendFile{}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								IDToken:     []string{"id-claim"}, +								AccessToken: []string{"at-claim"}, +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"id-claim": {Attribute: "email"}, +									"at-claim": {Attribute: "email"}, +								}, +							}, +						}, +					}, +				}, +			}, +		}, +		{ +			name: "ShouldNotAllowCustomClaimsAutoMissingAttribute", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					File: &schema.AuthenticationBackendFile{}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								IDToken:     []string{"id-claim"}, +								AccessToken: []string{"at-claim"}, +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"id-claim": {}, +									"at-claim": {}, +								}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: claims_policies: example: claim with name 'at-claim' has an attribute name 'at-claim' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'id-claim' has an attribute name 'id-claim' which is not a known attribute", +			}, +		}, +		{ +			name: "ShouldNotAllowCustomClaimsAutoMissingProvider", +			have: &schema.Configuration{ +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								IDToken:     []string{"id-claim"}, +								AccessToken: []string{"at-claim"}, +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"id-claim": {}, +									"at-claim": {}, +								}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: claims_policies: example: claim with name 'at-claim' has an attribute name 'at-claim' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'id-claim' has an attribute name 'id-claim' which is not a known attribute", +			}, +		}, +		{ +			name: "ShouldNotAllowCustomClaimsReserved", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					File: &schema.AuthenticationBackendFile{}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"sub": {Attribute: "email"}, +								}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: claims_policies: example: custom_claims: claim with name 'sub' can't be used in a claims policy as it's a standard claim", +			}, +		}, +		{ +			name: "ShouldAllowCustomClaimsAuto", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					File: &schema.AuthenticationBackendFile{ +						ExtraAttributes: map[string]schema.AuthenticationBackendExtraAttribute{ +							"id-claim": { +								ValueType: "string", +							}, +							"at-claim": { +								ValueType: "string", +							}, +						}, +					}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								IDToken:     []string{"id-claim"}, +								AccessToken: []string{"at-claim"}, +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"id-claim": {}, +									"at-claim": {}, +								}, +							}, +						}, +					}, +				}, +			}, +			expectf: func(t *testing.T, actual *schema.IdentityProvidersOpenIDConnect) { +				assert.Equal(t, "at-claim", actual.ClaimsPolicies["example"].CustomClaims["at-claim"].Attribute) +				assert.Equal(t, "id-claim", actual.ClaimsPolicies["example"].CustomClaims["id-claim"].Attribute) +			}, +		}, +		{ +			name: "ShouldNotAllowStandardClaimUnmappedLDAP", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					LDAP: &schema.AuthenticationBackendLDAP{}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								IDToken:     []string{"id-claim"}, +								AccessToken: []string{"at-claim"}, +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"id-claim":              {Attribute: "given_name"}, +									"at-claim":              {Attribute: "middle_name"}, +									"claim-family_name":     {Attribute: "family_name"}, +									"claim-nickname":        {Attribute: "nickname"}, +									"claim-profile":         {Attribute: "profile"}, +									"claim-picture":         {Attribute: "picture"}, +									"claim-website":         {Attribute: "website"}, +									"claim-gender":          {Attribute: "gender"}, +									"claim-birthdate":       {Attribute: "birthdate"}, +									"claim-zoneinfo":        {Attribute: "zoneinfo"}, +									"claim-locale":          {Attribute: "locale"}, +									"claim-phone_number":    {Attribute: "phone_number"}, +									"claim-phone_extension": {Attribute: "phone_extension"}, +									"claim-street_address":  {Attribute: "street_address"}, +									"claim-locality":        {Attribute: "locality"}, +									"claim-region":          {Attribute: "region"}, +									"claim-postal_code":     {Attribute: "postal_code"}, +									"claim-country":         {Attribute: "country"}, +									"claim-extra1":          {Attribute: "extra1"}, +								}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: claims_policies: example: claim with name 'at-claim' has an attribute name 'middle_name' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-birthdate' has an attribute name 'birthdate' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-country' has an attribute name 'country' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-extra1' has an attribute name 'extra1' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-family_name' has an attribute name 'family_name' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-gender' has an attribute name 'gender' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-locale' has an attribute name 'locale' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-locality' has an attribute name 'locality' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-nickname' has an attribute name 'nickname' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-phone_extension' has an attribute name 'phone_extension' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-phone_number' has an attribute name 'phone_number' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-picture' has an attribute name 'picture' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-postal_code' has an attribute name 'postal_code' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-profile' has an attribute name 'profile' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-region' has an attribute name 'region' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-street_address' has an attribute name 'street_address' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-website' has an attribute name 'website' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-zoneinfo' has an attribute name 'zoneinfo' which is not a known attribute", +				"identity_providers: oidc: claims_policies: example: claim with name 'id-claim' has an attribute name 'given_name' which is not a known attribute", +			}, +		}, +		{ +			name: "ShouldNotAllowStandardClaimUnmappedLDAPWithExtra", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					LDAP: &schema.AuthenticationBackendLDAP{ +						Attributes: schema.AuthenticationBackendLDAPAttributes{ +							Extra: map[string]schema.AuthenticationBackendLDAPAttributesAttribute{}, +						}, +					}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"claim-extra1": {Attribute: "extra1"}, +								}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-extra1' has an attribute name 'extra1' which is not a known attribute", +			}, +		}, +		{ +			name: "ShouldNotAllowStandardClaimMappedLDAPWithExtraMismatchedName", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					LDAP: &schema.AuthenticationBackendLDAP{ +						Attributes: schema.AuthenticationBackendLDAPAttributes{ +							Extra: map[string]schema.AuthenticationBackendLDAPAttributesAttribute{ +								"extra1": { +									Name: "x", +									AuthenticationBackendExtraAttribute: schema.AuthenticationBackendExtraAttribute{ +										ValueType: "string", +									}, +								}, +							}, +						}, +					}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"claim-extra1": {Attribute: "extra1"}, +								}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: claims_policies: example: claim with name 'claim-extra1' has an attribute name 'extra1' which is not a known attribute", +			}, +		}, +		{ +			name: "ShouldAllowStandardClaimMappedLDAPWithExtra", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{ +					LDAP: &schema.AuthenticationBackendLDAP{ +						Attributes: schema.AuthenticationBackendLDAPAttributes{ +							Extra: map[string]schema.AuthenticationBackendLDAPAttributesAttribute{ +								"extra1": { +									Name: "extra1", +									AuthenticationBackendExtraAttribute: schema.AuthenticationBackendExtraAttribute{ +										ValueType: "string", +									}, +								}, +								"extra2": { +									AuthenticationBackendExtraAttribute: schema.AuthenticationBackendExtraAttribute{ +										ValueType: "string", +									}, +								}, +							}, +						}, +					}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"claim-extra1": {Attribute: "extra1"}, +									"claim-extra2": {Attribute: "extra2"}, +									"claim-extra3": {Attribute: "username"}, +									"claim-extra4": {Attribute: "email"}, +								}, +							}, +						}, +					}, +				}, +			}, +		}, +		{ +			name: "ShouldAllowDefinitionClaim", +			have: &schema.Configuration{ +				Definitions: schema.Definitions{ +					UserAttributes: map[string]schema.UserAttribute{ +						"custom-1": {}, +					}, +				}, +				AuthenticationBackend: schema.AuthenticationBackend{ +					LDAP: &schema.AuthenticationBackendLDAP{}, +				}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +							"example": { +								CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +									"claim-extra1": {Attribute: "custom-1"}, +								}, +							}, +						}, +					}, +				}, +			}, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			val := schema.NewStructValidator() + +			validateOIDCClaims(tc.have, val) + +			if tc.expectf != nil { +				tc.expectf(t, tc.have.IdentityProviders.OIDC) +			} + +			errs := val.Errors() +			sort.Sort(utils.ErrSliceSortAlphabetical(errs)) + +			require.Len(t, errs, len(tc.errors)) + +			for i, err := range tc.errors { +				assert.EqualError(t, errs[i], err) +			} +		}) +	} +} + +func TestShouldValidateOpenIDConnectScopes(t *testing.T) { +	testCases := []struct { +		name    string +		have    *schema.Configuration +		expectf func(t *testing.T, actual *schema.IdentityProvidersOpenIDConnect) +		errors  []string +	}{ +		{ +			name: "ShouldValidateWithoutPolicies", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{File: &schema.AuthenticationBackendFile{}}, +				IdentityProviders:     schema.IdentityProviders{OIDC: &schema.IdentityProvidersOpenIDConnect{}}, +			}, +		}, +		{ +			name: "ShouldNotValidateUnknownClaim", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{File: &schema.AuthenticationBackendFile{}}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +							"scopes-ex": { +								Claims: []string{"example"}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: scopes: scopes-ex: claim with name 'example' is not a known claim", +			}, +		}, +		{ +			name: "ShouldValidateKnownClaim", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{File: &schema.AuthenticationBackendFile{}}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +							"scopes-ex": { +								Claims: []string{"example"}, +							}, +						}, +						Discovery: schema.IdentityProvidersOpenIDConnectDiscovery{ +							Claims: []string{"example"}, +						}, +					}, +				}, +			}, +		}, +		{ +			name: "ShouldNotValidateStandardClaim", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{File: &schema.AuthenticationBackendFile{}}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +							"scopes-ex": { +								Claims: []string{"sub"}, +							}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: scopes: scopes-ex: claim with name 'sub' can't be used in a custom scope as it's a standard claim", +			}, +		}, +		{ +			name: "ShouldNotValidateSpecialReservedScopes", +			have: &schema.Configuration{ +				AuthenticationBackend: schema.AuthenticationBackend{File: &schema.AuthenticationBackendFile{}}, +				IdentityProviders: schema.IdentityProviders{ +					OIDC: &schema.IdentityProvidersOpenIDConnect{ +						Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +							"authelia.scopes-ex": { +								Claims: []string{"example"}, +							}, +							"profile": {}, +						}, +						Discovery: schema.IdentityProvidersOpenIDConnectDiscovery{ +							Claims: []string{"example"}, +						}, +					}, +				}, +			}, +			errors: []string{ +				"identity_providers: oidc: scopes: scope with name 'authelia.scopes-ex' can't be used as a custom scope because all scopes prefixed with 'authelia.' are reserved", +				"identity_providers: oidc: scopes: scope with name 'profile' can't be used as a custom scope because it's a standard scope", +			}, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			val := schema.NewStructValidator() + +			validateOIDCScopes(tc.have, val) + +			if tc.expectf != nil { +				tc.expectf(t, tc.have.IdentityProviders.OIDC) +			} + +			errs := val.Errors() +			sort.Sort(utils.ErrSliceSortAlphabetical(errs)) + +			require.Len(t, errs, len(tc.errors)) + +			for i, err := range tc.errors { +				assert.EqualError(t, errs[i], err) +			} +		}) +	} +} +  func MustDecodeSecret(value string) *schema.PasswordDigest {  	if secret, err := schema.DecodePasswordDigest(value); err != nil {  		panic(err) diff --git a/internal/handlers/handler_oauth_device_authorization.go b/internal/handlers/handler_oauth_device_authorization.go index 2b5bf9bf0..d7c1560b3 100644 --- a/internal/handlers/handler_oauth_device_authorization.go +++ b/internal/handlers/handler_oauth_device_authorization.go @@ -118,6 +118,10 @@ func OAuthDeviceAuthorizationPUT(ctx *middlewares.AutheliaCtx, rw http.ResponseW  	session := oidc.NewSessionWithRequester(ctx, issuer, ctx.Providers.OpenIDConnect.Issuer.GetKeyID(ctx, client.GetIDTokenSignedResponseKeyID(), client.GetIDTokenSignedResponseAlg()), details.Username, userSession.AuthenticationMethodRefs.MarshalRFC8176(), extra, userSession.LastAuthenticatedTime(), consent, requester, requests) +	if client.GetClaimsStrategy().MergeAccessTokenClaimsIntoIDToken() { +		session.DefaultSession.Claims.Audience = append([]string{clientID}, requester.GetGrantedAudience()...) +	} +  	if responder, err = ctx.Providers.OpenIDConnect.NewRFC8628UserAuthorizeResponse(ctx, requester, session); err != nil {  		ctx.Logger.Errorf("Device Authorization Request with id '%s' failed with error: %s", requester.GetID(), oauthelia2.ErrorToDebugRFC6749Error(err)) diff --git a/internal/handlers/handler_oidc_authorization.go b/internal/handlers/handler_oidc_authorization.go index a7d95a6b3..adadf596f 100644 --- a/internal/handlers/handler_oidc_authorization.go +++ b/internal/handlers/handler_oidc_authorization.go @@ -139,6 +139,10 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  	session := oidc.NewSessionWithRequester(ctx, issuer, ctx.Providers.OpenIDConnect.Issuer.GetKeyID(ctx, client.GetIDTokenSignedResponseKeyID(), client.GetIDTokenSignedResponseAlg()), details.Username, userSession.AuthenticationMethodRefs.MarshalRFC8176(), extra, userSession.LastAuthenticatedTime(), consent, requester, requests) +	if client.GetClaimsStrategy().MergeAccessTokenClaimsIntoIDToken() { +		session.DefaultSession.Claims.Audience = append([]string{clientID}, requester.GetGrantedAudience()...) +	} +  	ctx.Logger.Tracef("Authorization Request with id '%s' on client with id '%s' using policy '%s' creating session for Authorization Response for subject '%s' with username '%s' with groups: %+v and claims: %+v",  		requester.GetID(), session.ClientID, policy.Name, session.Subject, session.Username, userSession.Groups, session.Claims) diff --git a/internal/oidc/claims.go b/internal/oidc/claims.go index 3e3f50198..ecef56644 100644 --- a/internal/oidc/claims.go +++ b/internal/oidc/claims.go @@ -394,6 +394,7 @@ type ClaimsStrategy interface {  	PopulateIDTokenClaims(ctx Context, strategy oauthelia2.ScopeStrategy, client Client, scopes, claims oauthelia2.Arguments, requests map[string]*ClaimRequest, detailer UserDetailer, updated time.Time, original, extra map[string]any) (err error)  	PopulateUserInfoClaims(ctx Context, strategy oauthelia2.ScopeStrategy, client Client, scopes, claims oauthelia2.Arguments, requests map[string]*ClaimRequest, detailer UserDetailer, updated time.Time, original, extra map[string]any) (err error)  	PopulateClientCredentialsUserInfoClaims(ctx Context, client Client, original, extra map[string]any) (err error) +	MergeAccessTokenClaimsIntoIDToken() (include bool)  }  func NewDefaultCustomClaimsStrategy() (strategy *CustomClaimsStrategy) { @@ -456,6 +457,10 @@ func NewCustomClaimsStrategy(client schema.IdentityProvidersOpenIDConnectClient,  		return strategy  	} +	if policy.IDTokenAudienceMode == IDTokenAudienceModeExperimentalMerged { +		strategy.mergeAccessTokenClaimsIntoIDToken = true +	} +  	if policy.IDToken != nil {  		strategy.claimsIDToken = policy.IDToken  	} @@ -502,6 +507,8 @@ type CustomClaimsStrategy struct {  	claimsIDToken     []string  	claimsAccessToken []string  	scopes            map[string]map[string]string + +	mergeAccessTokenClaimsIntoIDToken bool  }  //nolint:gocyclo @@ -626,6 +633,10 @@ func (s *CustomClaimsStrategy) PopulateClientCredentialsUserInfoClaims(ctx Conte  	return nil  } +func (s *CustomClaimsStrategy) MergeAccessTokenClaimsIntoIDToken() (include bool) { +	return s.mergeAccessTokenClaimsIntoIDToken +} +  func (s *CustomClaimsStrategy) isClaimAllowed(claim string, allowed []string) (isAllowed bool) {  	if allowed == nil {  		return true diff --git a/internal/oidc/const.go b/internal/oidc/const.go index f30bfbabc..bffae7d0a 100644 --- a/internal/oidc/const.go +++ b/internal/oidc/const.go @@ -233,17 +233,6 @@ const (  	// PromptCreate  = "create" // This prompt value is currently unused.  ) -// Endpoints. -const ( -	EndpointAuthorization              = "authorization" -	EndpointDeviceAuthorization        = "device-authorization" -	EndpointToken                      = "token" -	EndpointUserinfo                   = "userinfo" -	EndpointIntrospection              = "introspection" -	EndpointRevocation                 = "revocation" -	EndpointPushedAuthorizationRequest = "pushed-authorization-request" -) -  // JWT Headers.  const (  	// JWTHeaderKeyIdentifier is the JWT Header referencing the JWS Key Identifier used to sign a token. @@ -260,6 +249,22 @@ const (  	JWTHeaderTypeValueAccessTokenJWT = "at+jwt"  ) +const ( +	IDTokenAudienceModeSpecification      = "specification" +	IDTokenAudienceModeExperimentalMerged = "experimental-merged" +) + +// Endpoints. +const ( +	EndpointAuthorization              = "authorization" +	EndpointDeviceAuthorization        = "device-authorization" +	EndpointToken                      = "token" +	EndpointUserinfo                   = "userinfo" +	EndpointIntrospection              = "introspection" +	EndpointRevocation                 = "revocation" +	EndpointPushedAuthorizationRequest = "pushed-authorization-request" +) +  // Paths.  const (  	EndpointPathConsent         = "/consent/openid" diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index 4782eb3e5..50f710923 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -35,7 +35,7 @@ i18n.use(Backend)              loadPath: basePath + "/locales/{{lng}}/{{ns}}.json",          },          load: "all", -        ns: ["portal", "settings", "consent"], +        ns: ["consent", "portal", "settings"],          defaultNS: "portal",          fallbackLng: {              default: ["en"],  | 
