diff options
| author | James Elliott <james-d-elliott@users.noreply.github.com> | 2025-02-22 19:20:34 +1100 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-02-22 08:20:34 +0000 | 
| commit | 111344eaea4fd0c32ce58a181b94414ae639fe2b (patch) | |
| tree | 76e024658c1e2483795a8153fe18661ca035c138 | |
| parent | 9c718b39888bbaafdbc623acd0efd2138b6b8068 (diff) | |
feat(oidc): claims parameter support (#8081)
This adds formal support for the claims parameter.
Closes #2868
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
58 files changed, 4374 insertions, 711 deletions
diff --git a/cmd/authelia-gen/cmd_docs_data.go b/cmd/authelia-gen/cmd_docs_data.go index 9ad339280..36dd4423b 100644 --- a/cmd/authelia-gen/cmd_docs_data.go +++ b/cmd/authelia-gen/cmd_docs_data.go @@ -126,6 +126,10 @@ func docsKeysRunE(cmd *cobra.Command, args []string) (err error) {  	keys := readTags("", reflect.TypeOf(schema.Configuration{}), true, true)  	for _, key := range keys { +		if strings.HasSuffix(key, ".*") { +			continue +		} +  		ck := ConfigurationKey{  			Path:   key,  			Secret: configuration.IsSecretKey(key), diff --git a/cmd/authelia-gen/helpers.go b/cmd/authelia-gen/helpers.go index 94c9b2cf5..0a9894da4 100644 --- a/cmd/authelia-gen/helpers.go +++ b/cmd/authelia-gen/helpers.go @@ -154,7 +154,11 @@ func readTags(prefix string, t reflect.Type, envSkip, deprecatedSkip bool) (tags  				continue  			} -		case reflect.Slice, reflect.Map: +		case reflect.Map: +			tags = append(tags, getKeyNameFromTagAndPrefix(prefix, tag, false, true)) + +			fallthrough +		case reflect.Slice:  			k := field.Type.Elem().Kind()  			if envSkip && !isValueKind(k) { diff --git a/docs/content/configuration/definitions/_index.md b/docs/content/configuration/definitions/_index.md index 156068ab3..0cb428617 100644 --- a/docs/content/configuration/definitions/_index.md +++ b/docs/content/configuration/definitions/_index.md @@ -2,7 +2,7 @@  title: "Definitions"  description: "Definitions Configuration"  summary: "" -date: 2025-02-18T09:38:36+00:00 +date: 2024-11-12T22:08:06+11:00  draft: false  images: []  weight: 198000 diff --git a/docs/content/configuration/definitions/introduction.md b/docs/content/configuration/definitions/introduction.md index 5ea06d53c..a96b8ce7d 100644 --- a/docs/content/configuration/definitions/introduction.md +++ b/docs/content/configuration/definitions/introduction.md @@ -2,7 +2,7 @@  title: "Definitions"  description: "Definitions Configuration"  summary: "Authelia allows configuring reusable definitions." -date: 2025-02-18T09:38:36+00:00 +date: 2024-11-12T22:08:06+11:00  draft: false  images: []  weight: 199100 diff --git a/docs/content/configuration/definitions/network.md b/docs/content/configuration/definitions/network.md index 5c203340b..224824342 100644 --- a/docs/content/configuration/definitions/network.md +++ b/docs/content/configuration/definitions/network.md @@ -2,7 +2,7 @@  title: "Network"  description: "Network Definitions Configuration"  summary: "Authelia allows configuring reusable network definitions." -date: 2025-02-18T09:38:36+00:00 +date: 2024-11-12T22:08:06+11:00  draft: false  images: []  weight: 199100 diff --git a/docs/content/configuration/definitions/user-attributes.md b/docs/content/configuration/definitions/user-attributes.md index e64f515e5..c9aeaf06a 100644 --- a/docs/content/configuration/definitions/user-attributes.md +++ b/docs/content/configuration/definitions/user-attributes.md @@ -2,7 +2,7 @@  title: "User Attributes"  description: "User Attributes Definitions Configuration"  summary: "Authelia allows configuring reusable user attribute definitions." -date: 2025-02-22T16:18:09+11:00 +date: 2024-11-12T22:11:32+11:00  draft: false  images: []  weight: 199100 @@ -17,7 +17,7 @@ seo:  The user attributes section allows you to define custom attributes for your users using Common Expression Language (CEL).  These attributes can be used at the current time to: -- Enhance OpenID Connect claims with dynamic values +- Enhance [OpenID Connect 1.0 claims](../../integration/openid-connect/openid-connect-1.0-claims.md) with dynamic values  ## Configuration diff --git a/docs/content/configuration/identity-providers/openid-connect/clients.md b/docs/content/configuration/identity-providers/openid-connect/clients.md index 3a853c863..df9a43233 100644 --- a/docs/content/configuration/identity-providers/openid-connect/clients.md +++ b/docs/content/configuration/identity-providers/openid-connect/clients.md @@ -64,6 +64,7 @@ identity_providers:            - 'fragment'          authorization_policy: 'two_factor'          lifespan: '' +        claims_policy: ''          requested_audience_mode: 'explicit'          consent_mode: 'explicit'          pre_configured_consent_duration: '1 week' @@ -329,6 +330,13 @@ identity_providers:  The name of the custom lifespan that this client uses. A custom lifespan is named and configured globally via the  [custom](provider.md#custom) section within [lifespans](provider.md#lifespans). +### claims_policy + +{{< confkey type="string" default="" required="no" >}} + +The name of the claims policy that this client uses. A claims policy is named and configured globally via the +[claims_policies](provider.md#claims_policies) for the OpenID Connect 1.0 Provider. +  ### requested_audience_mode  {{< confkey type="string" default="explicit" required="no" >}} diff --git a/docs/content/configuration/identity-providers/openid-connect/provider.md b/docs/content/configuration/identity-providers/openid-connect/provider.md index 7d6d4068b..e1752d9cb 100644 --- a/docs/content/configuration/identity-providers/openid-connect/provider.md +++ b/docs/content/configuration/identity-providers/openid-connect/provider.md @@ -84,6 +84,16 @@ identity_providers:        authorize_code: '1m'        id_token: '1h'        refresh_token: '90m' +    claims_policies: +      policy_name: +        id_token: [] +        access_token: [] +        custom_claims: +          claim_name: +            attribute: 'attribute_name' +    scopes: +      scope_name: +        claims: []      cors:        endpoints:          - 'authorization' @@ -554,6 +564,64 @@ identity_providers:                refresh_token: '90m'  ``` +### claims_policies + +{{< confkey type="string" syntax="dictionary" common="dictionary-reference" required="no" >}} + +The claims policies are policies which allow customizing the behaviour of claims and the available claims for a +particular client. + +The keys under `claims_policies` is an arbitrary value that can be used in the +[OpenID Connect 1.0 Client](clients.md#claims_policy) as the [claims_policy](clients.md#claims_policy) value. + +#### id_token + +{{< confkey type="list(string)" required="no" >}} + +The list of claims automatically copied to the ID Token in addition to the standard ID Token claims provided the +relevant scope was granted. + +#### access_token + +{{< confkey type="list(string)" required="no" >}} + +The list of claims automatically copied to the Access Token in addition to the standard JWT Profile claims provided the +relevant scope was granted. + +#### custom_claims + +{{< confkey type="string" syntax="dictionary" common="dictionary-reference" required="no" >}} + +The list of claims available in this policy in addition to the standard claims. These claims are anchored to attributes +which can either be concrete attributes from the [first factor](../../first-factor/introduction.md) backend or can be +those defined via [definitions](../../definitions/user-attributes.md). + +The keys under `custom_claims` are arbitrary values which are the names of the claims. + +##### attribute + +{{< confkey type="string" required="no" >}} + +The attribute name that this claim returns. By default it's the same as the claim name. + +### scopes + +{{< confkey type="string" syntax="dictionary" common="dictionary-reference" required="no" >}} + +A list of scope definitions available in addition to the standard ones. + +The keys under `scopes` are arbitrary values which are the names of the scopes. + +#### claims + +{{< confkey type="list(string)" required="no" >}} + +The claims to be available to this scope. + +If the scope is configured in a [OpenID Connect 1.0 Client](clients.md#scopes) in the [scopes](clients.md#scopes) then +every claim available in this list must either be a Standard Claim or must be fulfilled by the +[claims_policy](clients.md#claims_policy). +  ### cors  Some [OpenID Connect 1.0] Endpoints need to allow cross-origin resource sharing; however, some are optional. This section allows diff --git a/docs/content/integration/openid-connect/expressjs/index.md b/docs/content/integration/openid-connect/expressjs/index.md index 19a6f0eef..6c25df4d1 100644 --- a/docs/content/integration/openid-connect/expressjs/index.md +++ b/docs/content/integration/openid-connect/expressjs/index.md @@ -118,9 +118,11 @@ app.get('/', requiresAuth(), (req, res) => {          accessToken: req.oidc.accessToken,          refreshToken: req.oidc.refreshToken,          idToken: req.oidc.idToken, -        claims: req.oidc.idTokenClaims, +        claims: { +          id_token: req.oidc.idToken, +          userinfo: userInfo, +        },          scopes: req.oidc.scope, -        userInfo,        }, null, 2);      res.send(`<html lang='en'><body><pre><code>${data}</code></pre></body></html>`); diff --git a/docs/content/integration/openid-connect/introduction.md b/docs/content/integration/openid-connect/introduction.md index eb5807080..27f655fda 100644 --- a/docs/content/integration/openid-connect/introduction.md +++ b/docs/content/integration/openid-connect/introduction.md @@ -52,106 +52,6 @@ the Authorization Flow.  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). -## Scope Definitions - -The following scope definitions describe each scope supported and the associated effects including the individual claims -returned by granting this scope. By default, we do not issue any claims which reveal the users identity which allows -administrators semi-granular control over which claims the client is entitled to. - -### openid - -This is the default scope for [OpenID Connect 1.0]. This field is forced on every client by the configuration validation -that Authelia does. - -{{< callout context="caution" title="Important Note" icon="outline/alert-triangle" >}} -The combination of the issuer (i.e. `iss`) [Claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) and -subject (i.e. `sub`) [Claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) are utilized to uniquely -identify a -user and per the specification the only reliable way to do so as they are guaranteed to be a unique combination. As such -this is the supported method for linking an account to Authelia. The `preferred_username` and `email` claims from the -`profile` and `email` scopes respectively should only be utilized for provisioning a new account. - -In addition, the `sub` [Claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) utilizes -a [RFC4122](https://datatracker.ietf.org/doc/html/rfc4122) UUID V4 to identify the individual user as per the -[Subject Identifier Types](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes) section of -the [OpenID Connect 1.0](https://openid.net/connect/) specification. -{{< /callout >}} - -|  [Claim]  |   JWT Type    | Authelia Attribute |                         Description                         | -|:---------:|:-------------:|:------------------:|:-----------------------------------------------------------:| -|    iss    |    string     |      hostname      |             The issuer name, determined by URL              | -|    jti    | string(uuid)  |       *N/A*        |     A [RFC4122] UUID V4 representing the JWT Identifier     | -|    rat    |    number     |       *N/A*        |            The time when the token was requested            | -|    exp    |    number     |       *N/A*        |                           Expires                           | -|    iat    |    number     |       *N/A*        |             The time when the token was issued              | -| auth_time |    number     |       *N/A*        |        The time the user authenticated with Authelia        | -|    sub    | string(uuid)  |     opaque id      |    A [RFC4122] UUID V4 linked to the user who logged in     | -|   scope   |    string     |       scopes       |              Granted scopes (space delimited)               | -|    scp    | array[string] |       scopes       |                       Granted scopes                        | -|    aud    | array[string] |       *N/A*        |                          Audience                           | -|    amr    | array[string] |       *N/A*        | An [RFC8176] list of authentication method reference values | -|    azp    |    string     |    id (client)     |                    The authorized party                     | -| client_id |    string     |    id (client)     |                        The client id                        | - -### offline_access - -This scope is a special scope designed to allow applications to obtain a [Refresh Token] which allows extended access to -an application on behalf of a user. A [Refresh Token] is a special [Access Token] that allows refreshing previously -issued token credentials, effectively it allows the Relying Party to obtain new tokens periodically. - -As per [OpenID Connect 1.0] Section 11 [Offline Access] can only be granted during the [Authorization Code Flow] or a -[Hybrid Flow]. The [Refresh Token] will only ever be returned at the [Token Endpoint] when the client is exchanging -their [OAuth 2.0 Authorization Code]. - -Generally unless an application supports this and actively requests this scope they should not be granted this scope via -the client configuration. - -It is also important to note that we treat a [Refresh Token] as single use and reissue a new [Refresh Token] during the -refresh flow. - -### groups - -This scope includes the groups the authentication backend reports the user is a member of in the [Claims] of the -[ID Token]. - -| [Claim] |   JWT Type    | Authelia Attribute |                                               Description                                               | -|:-------:|:-------------:|:------------------:|:-------------------------------------------------------------------------------------------------------:| -| groups  | array[string] |       groups       | List of user's groups discovered via [authentication](../../configuration/first-factor/introduction.md) | - -### email - -This scope includes the email information the authentication backend reports about the user in the [Claims] of the -[ID Token]. - -|     Claim      |   JWT Type    | Authelia Attribute |                        Description                        | -|:--------------:|:-------------:|:------------------:|:---------------------------------------------------------:| -|     email      |    string     |      email[0]      |       The first email address in the list of emails       | -| email_verified |     bool      |       *N/A*        | If the email is verified, assumed true for the time being | -|   alt_emails   | array[string] |     email[1:]      |  All email addresses that are not in the email JWT field  | - -### profile - -This scope includes the profile information the authentication backend reports about the user in the [Claims] of the -[ID Token]. - -|       Claim        | JWT Type | Authelia Attribute |               Description                | -|:------------------:|:--------:|:------------------:|:----------------------------------------:| -| preferred_username |  string  |      username      | The username the user used to login with | -|        name        |  string  |    display_name    |          The users display name          | - -### Special Scopes - -The following scopes represent special permissions granted to a specific token. - -#### authelia.bearer.authz - -This scope allows the granted access token to be utilized with the bearer authorization scheme on endpoints protected -via Authelia. - -The specifics about this scope are discussed in the -[OAuth 2.0 Bearer Token Usage for Authorization Endpoints](oauth-2.0-bearer-token-usage.md#authorization-endpoints) -guide. -  ## Signing and Encryption Algorithms  [OpenID Connect 1.0] and OAuth 2.0 support a wide variety of signature and encryption algorithms. Authelia supports @@ -177,21 +77,21 @@ Authelia's response objects can have the following signature algorithms:  Authelia accepts a wide variety of request object types. The below table describes these request objects. -| Algorithm |      Key Type      | Hashing Algorithm |    Use    |                       Notes                        | -|:---------:|:------------------:|:-----------------:|:---------:|:--------------------------------------------------:| -|   none    |        None        |       None        |    N/A    |                        N/A                         | -|   HS256   | HMAC Shared Secret |      SHA-256      | Signature | [Client Authentication Method] `client_secret_jwt` | -|   HS384   | HMAC Shared Secret |      SHA-384      | Signature | [Client Authentication Method] `client_secret_jwt` | -|   HS512   | HMAC Shared Secret |      SHA-512      | Signature | [Client Authentication Method] `client_secret_jwt` | -|   RS256   |        RSA         |      SHA-256      | Signature |  [Client Authentication Method] `private_key_jwt`  | -|   RS384   |        RSA         |      SHA-384      | Signature |  [Client Authentication Method] `private_key_jwt`  | -|   RS512   |        RSA         |      SHA-512      | Signature |  [Client Authentication Method] `private_key_jwt`  | -|   ES256   |    ECDSA P-256     |      SHA-256      | Signature |  [Client Authentication Method] `private_key_jwt`  | -|   ES384   |    ECDSA P-384     |      SHA-384      | Signature |  [Client Authentication Method] `private_key_jwt`  | -|   ES512   |    ECDSA P-521     |      SHA-512      | Signature |  [Client Authentication Method] `private_key_jwt`  | -|   PS256   |     RSA (MFG1)     |      SHA-256      | Signature |  [Client Authentication Method] `private_key_jwt`  | -|   PS384   |     RSA (MFG1)     |      SHA-384      | Signature |  [Client Authentication Method] `private_key_jwt`  | -|   PS512   |     RSA (MFG1)     |      SHA-512      | Signature |  [Client Authentication Method] `private_key_jwt`  | +| Algorithm |      Key Type      | Hashing Algorithm |    Use    | [Client Authentication Method] | +|:---------:|:------------------:|:-----------------:|:---------:|:------------------------------:| +|   none    |        None        |       None        |    N/A    |              N/A               | +|   HS256   | HMAC Shared Secret |      SHA-256      | Signature |      `client_secret_jwt`       | +|   HS384   | HMAC Shared Secret |      SHA-384      | Signature |      `client_secret_jwt`       | +|   HS512   | HMAC Shared Secret |      SHA-512      | Signature |      `client_secret_jwt`       | +|   RS256   |        RSA         |      SHA-256      | Signature |       `private_key_jwt`        | +|   RS384   |        RSA         |      SHA-384      | Signature |       `private_key_jwt`        | +|   RS512   |        RSA         |      SHA-512      | Signature |       `private_key_jwt`        | +|   ES256   |    ECDSA P-256     |      SHA-256      | Signature |       `private_key_jwt`        | +|   ES384   |    ECDSA P-384     |      SHA-384      | Signature |       `private_key_jwt`        | +|   ES512   |    ECDSA P-521     |      SHA-512      | Signature |       `private_key_jwt`        | +|   PS256   |     RSA (MGF1)     |      SHA-256      | Signature |       `private_key_jwt`        | +|   PS384   |     RSA (MGF1)     |      SHA-384      | Signature |       `private_key_jwt`        | +|   PS512   |     RSA (MGF1)     |      SHA-512      | Signature |       `private_key_jwt`        |  [Client Authentication Method]: #client-authentication-method @@ -303,31 +203,6 @@ Per the [RFC7523 Section 3: JWT Format and Processing Requirements](https://data  this claim must be compared using [RFC3987 Section 6.2.1: Simple String Comparison] and to assist with making this  predictable for implementers we ensure the comparison is done against the lowercase form of this URL. -## Authentication Method References - -Authelia currently supports adding the `amr` [Claim] to the [ID Token] utilizing the [RFC8176] Authentication Method -Reference values. - -The values this [Claim] has, are not strictly defined by the [OpenID Connect 1.0] specification. As such, some backends -may -expect a specification other than [RFC8176] for this purpose. If you have such an application and wish for us to support -it then you're encouraged to create a [feature request](https://www.authelia.com/l/fr). - -Below is a list of the potential values we place in the [Claim] and their meaning: - -| Value |                            Description                            | Factor | Channel  | -|:-----:|:-----------------------------------------------------------------:|:------:|:--------:| -|  mfa  |      User used multiple factors to login (see factor column)      |  N/A   |   N/A    | -|  mca  |     User used multiple channels to login (see channel column)     |  N/A   |   N/A    | -| user  |  User confirmed they were present when using their hardware key   |  N/A   |   N/A    | -|  pin  | User confirmed they are the owner of the hardware key with a pin  |  N/A   |   N/A    | -|  pwd  |            User used a username and password to login             |  Know  | Browser  | -|  otp  |                      User used TOTP to login                      |  Have  | Browser  | -|  pop  | User used a software or hardware proof-of-possession key to login |  Have  | Browser  | -|  hwk  |       User used a hardware proof-of-possession key to login       |  Have  | Browser  | -|  swk  |       User used a software proof-of-possession key to login       |  Have  | Browser  | -|  sms  |                      User used Duo to login                       |  Have  | External | -  ## Introspection Signing Algorithm  The following table describes the response from the [Introspection] endpoint depending on the @@ -521,7 +396,6 @@ The advantages of this approach are as follows:  [RFC4122]: https://datatracker.ietf.org/doc/html/rfc4122  [RFC7636]: https://datatracker.ietf.org/doc/html/rfc7636 -[RFC8176]: https://datatracker.ietf.org/doc/html/rfc8176  [RFC9126]: https://datatracker.ietf.org/doc/html/rfc9126  [RFC7519]: https://datatracker.ietf.org/doc/html/rfc7519  [RFC9068]: https://datatracker.ietf.org/doc/html/rfc9068 diff --git a/docs/content/integration/openid-connect/nextcloud/index.md b/docs/content/integration/openid-connect/nextcloud/index.md index 773971f36..0eb2dd29e 100644 --- a/docs/content/integration/openid-connect/nextcloud/index.md +++ b/docs/content/integration/openid-connect/nextcloud/index.md @@ -109,7 +109,7 @@ $CONFIG = array (      'oidc_login_end_session_redirect' => false,      'oidc_login_button_text' => 'Log in with Authelia',      'oidc_login_hide_password_form' => false, -    'oidc_login_use_id_token' => true, +    'oidc_login_use_id_token' => false,      'oidc_login_attributes' => array (          'id' => 'preferred_username',          'name' => 'name', diff --git a/docs/content/integration/openid-connect/openid-connect-1.0-claims.md b/docs/content/integration/openid-connect/openid-connect-1.0-claims.md new file mode 100644 index 000000000..2f3d8a1ec --- /dev/null +++ b/docs/content/integration/openid-connect/openid-connect-1.0-claims.md @@ -0,0 +1,297 @@ +--- +title: "OpenID Connect 1.0 Claims" +description: "An introduction into utilizing the Authelia OpenID Connect 1.0 Claims functionality" +summary: "An introduction into utilizing the Authelia OpenID Connect 1.0 Claims functionality." +date: 2024-03-05T19:11:16+10:00 +draft: false +images: [] +weight: 611 +toc: true +seo: +  title: "" # custom title (optional) +  description: "" # custom description (recommended) +  canonical: "" # custom canonical URL (optional) +  noindex: false # false (default) or true +--- + +## Claims + +The [OAuth 2.0] and [OpenID Connect 1.0] effectively names the individual content of a token as a [Claim]. Each [Claim] +can either be granted individually via: + +1. The requested and granted [Scope] which generally makes the associated [Claim] values available at the [UserInfo] +   endpoint, **_not_** within the [ID Token]. +2. The [Claims Parameter] which can request the authorization server explicitly +   include a [Claim] in the [ID Token] and/or [UserInfo] endpoint. + +Authelia supports several claims related features: + +1. The availability of the [Standard Claims] using the appropriate [scopes](#scope-definitions): +   * These claims come from the standard attributes available in the authentication backends, usually with the attribute +   of the same name. +2. The availability of creating your own [custom claims](#custom-claims) and [custom scopes](#custom-scopes). +3. The ability to create [Custom Attributes] to bolster the [custom claims](#custom-claims) functionality. +4. The ability to request individual claims by clients with the [Claims Parameter] assuming the client is allowed to +   request the specified claim. + +Because Authelia supports the [Claims Parameter] the [ID Token] only returns claims in a way that is privacy and +security focused, resulting in a minimal [ID Token] which solely proves authorization occurred. This allows various +flows which require the relying party to share the [ID Token] with another party to prove they have been authorized, and +relies on the [Access Token] which should be kept completely private to request the additional granted claims from the +[UserInfo] endpoint. + +The [Scope Definitions] indicate the default locations for a specific claim depending on the granted [Scope] when the +[Claims Parameter] is not used and the default behaviour is not overridden by the registered client configuration. + +[Scope Definitions]: #scope-definitions +[Scope]: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims +[Claims Parameter]: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter + +### Authentication Method References + +Authelia currently supports adding the `amr` [Claim] to the [ID Token] utilizing the [RFC8176] Authentication Method +Reference values. + +The values this [Claim] has, are not strictly defined by the [OpenID Connect 1.0] specification. As such, some backends +may +expect a specification other than [RFC8176] for this purpose. If you have such an application and wish for us to support +it then you're encouraged to create a [feature request](https://www.authelia.com/l/fr). + +Below is a list of the potential values we place in the [Claim] and their meaning: + +| Value |                            Description                            | Factor | Channel  | +|:-----:|:-----------------------------------------------------------------:|:------:|:--------:| +|  mfa  |      User used multiple factors to login (see factor column)      |  N/A   |   N/A    | +|  mca  |     User used multiple channels to login (see channel column)     |  N/A   |   N/A    | +| user  |  User confirmed they were present when using their hardware key   |  N/A   |   N/A    | +|  pin  | User confirmed they are the owner of the hardware key with a pin  |  N/A   |   N/A    | +|  pwd  |            User used a username and password to login             |  Know  | Browser  | +|  otp  |                      User used TOTP to login                      |  Have  | Browser  | +|  pop  | User used a software or hardware proof-of-possession key to login |  Have  | Browser  | +|  hwk  |       User used a hardware proof-of-possession key to login       |  Have  | Browser  | +|  swk  |       User used a software proof-of-possession key to login       |  Have  | Browser  | +|  sms  |                      User used Duo to login                       |  Have  | External | + +### Custom Claims + +Authelia supports methods to define your own claims. These can either come from [Standard Attributes] or +[Custom Attributes]. These claims are delivered to provided via a claims policy which describes which claims are +available to specific scopes. + +In the example below we configure a claims policy named `custom_claims_policy` which provides a claim named `claim_name` +which comes from the attribute named `attribute_name`. The claim `claim_name` is then made available via the scope named +`scope_name`. + +This policy and scope is then configured within the client with client id `client_example_id`. The client can then +either request the claim via the `claims` parameter or via the `scope` parameter. + +```yaml +identity_providers: +  oidc: +    claims_policies: +      custom_claims_policy: +        custom_claims: +          claim_name: +            attribute: 'attribute_name' +    scopes: +      scope_name: +        claims: +          - 'claim_name' +    clients: +      - client_id: 'client_example_id' +        claims_policy: 'custom_claims_policy' +        scopes: +          - 'scope_name' +``` + +### Restore Functionality Prior to Claims Parameter + +The introduction of the claims parameter has removed most claims from the [ID Token]. This may not work for some clients +which do not make requests to the user information endpoint which contains all of these claims, and they may not support +the claims parameter. + +The following example is a claims policy which restores that behaviour for those clients. Users may choose to expand +on this on their own as they desire. This example also shows how to apply this policy to a client using the +`claims_policy` option. + +We strongly recommend implementers use the standard process for obtaining the extra claims not generally intended to be +included in the [ID Token] by using the [Access Token] to obtain them from the [UserInfo Endpoint]. This process is +considered significantly more stable and forms the basis for the future guarantee. This option only exists as a +break-glass measure and is only offered on a best-effort basis. + +```yaml +identity_providers: +  oidc: +    claims_policies: +      default: +        id_token: ['groups', 'email', 'email_verified', 'alt_emails', 'preferred_username', 'name'] +    clients: +      - client_id: 'client_example_id' +        claims_policy: 'default' +``` + +## Scope Definitions + +The following scope definitions describe each scope supported and the associated effects including the individual claims +returned by granting this scope. By default, we do not issue any claims which reveal the users identity which allows +administrators semi-granular control over which claims the client is entitled to. + +### openid + +This is the default scope for [OpenID Connect 1.0]. This field is forced on every client by the configuration validation +that Authelia does. + +{{< callout context="caution" title="Important Note" icon="outline/alert-triangle" >}} +The combination of the issuer (i.e. `iss`) [Claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) and +subject (i.e. `sub`) [Claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) are utilized to uniquely +identify a +user and per the specification the only reliable way to do so as they are guaranteed to be a unique combination. As such +this is the supported method for linking an account to Authelia. The `preferred_username` and `email` claims from the +`profile` and `email` scopes respectively should only be utilized for provisioning a new account. + +In addition, the `sub` [Claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) utilizes +a [RFC4122](https://datatracker.ietf.org/doc/html/rfc4122) UUID V4 to identify the individual user as per the +[Subject Identifier Types](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes) section of +the [OpenID Connect 1.0](https://openid.net/connect/) specification. +{{< /callout >}} + +|  [Claim]  |   JWT Type    | Authelia Attribute | Default Location |                         Description                         | +|:---------:|:-------------:|:------------------:|:----------------:|:-----------------------------------------------------------:| +|    iss    |    string     |      hostname      |    [ID Token]    |             The issuer name, determined by URL              | +|    jti    | string(uuid)  |       *N/A*        |    [ID Token]    |     A [RFC4122] UUID V4 representing the JWT Identifier     | +|    sub    | string(uuid)  |     opaque id      |    [ID Token]    |    A [RFC4122] UUID V4 linked to the user who logged in     | +|    aud    | array[string] |       *N/A*        |    [ID Token]    |                          Audience                           | +|    exp    |    number     |       *N/A*        |    [ID Token]    |                           Expires                           | +|    iat    |    number     |       *N/A*        |    [ID Token]    |             The time when the token was issued              | +| auth_time |    number     |       *N/A*        |    [ID Token]    |        The time the user authenticated with Authelia        | +|    rat    |    number     |       *N/A*        |    [ID Token]    |            The time when the token was requested            | +|   nonce   |    string     |       *N/A*        |    [ID Token]    |        The time the user authenticated with Authelia        | +|    amr    | array[string] |       *N/A*        |    [ID Token]    | An [RFC8176] list of authentication method reference values | +|    azp    |    string     |    id (client)     |    [ID Token]    |                    The authorized party                     | +|   scope   |    string     |       scopes       |    [UserInfo]    |              Granted scopes (space delimited)               | +|    scp    | array[string] |       scopes       |    [UserInfo]    |                       Granted scopes                        | +| client_id |    string     |    id (client)     |    [UserInfo]    |                        The client id                        | + +### offline_access + +This scope is a special scope designed to allow applications to obtain a [Refresh Token] which allows extended access to +an application on behalf of a user. A [Refresh Token] is a special [Access Token] that allows refreshing previously +issued token credentials, effectively it allows the Relying Party to obtain new tokens periodically. + +As per [OpenID Connect 1.0] Section 11 [Offline Access] can only be granted during the [Authorization Code Flow] or a +[Hybrid Flow]. The [Refresh Token] will only ever be returned at the [Token Endpoint] when the client is exchanging +their [OAuth 2.0 Authorization Code]. + +Generally unless an application supports this and actively requests this scope they should not be granted this scope via +the client configuration. + +It is also important to note that we treat a [Refresh Token] as single use and reissue a new [Refresh Token] during the +refresh flow. + +### profile + +This scope allows the client to access the profile information the authentication backend reports about the user. + +|      [Claim]       | JWT Type | Authelia Attribute | Default Location |               Description                | +|:------------------:|:--------:|:------------------:|:----------------:|:----------------------------------------:| +|        name        |  string  |    display_name    |    [UserInfo]    |          The users display name          | +|    family_name     |  string  |    family_name     |    [UserInfo]    |          The users family name           | +|     given_name     |  string  |     given_name     |    [UserInfo]    |           The users given name           | +|    	middle_name    |  string  |    	middle_name    |    [UserInfo]    |          The users middle name           | +|      nickname      |  string  |      nickname      |    [UserInfo]    |            The users nickname            | +| preferred_username |  string  |      username      |    [UserInfo]    | The username the user used to login with | +|      profile       |  string  |      profile       |    [UserInfo]    |          The users profile URL           | +|      picture       |  string  |      picture       |    [UserInfo]    |          The users picture URL           | +|      website       |  string  |      website       |    [UserInfo]    |          The users website URL           | +|       gender       |  string  |       gender       |    [UserInfo]    |             The users gender             | +|     birthdate      |  string  |     birthdate      |    [UserInfo]    |           The users birthdate            | +|      zoneinfo      |  string  |      zoneinfo      |    [UserInfo]    |            The users zoneinfo            | +|       locale       |  string  |       locale       |    [UserInfo]    |             The users locale             | + +### email + +This scope allows the client to access the email information the authentication backend reports about the user. + +|    [Claim]     |   JWT Type    | Authelia Attribute | Default Location |                        Description                        | +|:--------------:|:-------------:|:------------------:|:----------------:|:---------------------------------------------------------:| +|     email      |    string     |      email[0]      |    [UserInfo]    |       The first email address in the list of emails       | +| email_verified |     bool      |       *N/A*        |    [UserInfo]    | If the email is verified, assumed true for the time being | +|   alt_emails   | array[string] |     email[1:]      |    [UserInfo]    |  All email addresses that are not in the email JWT field  | + +### address + +This scope allows the client to access the address information the authentication backend reports about the user. See +the [Address Claim](https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim) definition for information on +the format of this claim. + +| [Claim] | JWT Type | Authelia Attribute | Default Location |          Description          | +|:-------:|:--------:|:------------------:|:----------------:|:-----------------------------:| +| address |  object  |      various       |    [UserInfo]    | The users address information | + +The following table indicates the various sub-claims within the address claim. + +|    [Claim]     | JWT Type | Authelia Attribute |                       Description                       | +|:--------------:|:--------:|:------------------:|:-------------------------------------------------------:| +| street_address |  string  |   street_address   |                The users street address                 | +|    locality    |  string  |      locality      |             The users locality such as city             | +|     region     |  string  |       region       | The users region such as state, province, or prefecture | +|  postal_code   |  string  |    postal_code     |                   The users postcode                    | +|    country     |  string  |      country       |                    The users country                    | + +### phone + +This scope allows the client to access the address information the authentication backend reports about the user. + +|        [Claim]        | JWT Type |       Authelia Attribute       | Default Location |                                              Description                                              | +|:---------------------:|:--------:|:------------------------------:|:----------------:|:-----------------------------------------------------------------------------------------------------:| +|     phone_number      |  string  | phone_number + phone_extension |    [UserInfo]    | The combination of the users phone number and extension in the format specified in OpenID Connect 1.0 | +| phone_number_verified | boolean  |              N/A               |    [UserInfo]    |                        Currently returns true if the phone number has a value.                        | + +### groups + +This scope includes the groups the authentication backend reports the user is a member of in the [Claims] of the +[ID Token]. + +| [Claim] |   JWT Type    | Authelia Attribute | Default Location |                                               Description                                               | +|:-------:|:-------------:|:------------------:|:----------------:|:-------------------------------------------------------------------------------------------------------:| +| groups  | array[string] |       groups       |    [UserInfo]    | List of user's groups discovered via [authentication](../../configuration/first-factor/introduction.md) | + +### Special Scopes + +The following scopes represent special permissions granted to a specific token. + +#### authelia.bearer.authz + +This scope allows the granted access token to be utilized with the bearer authorization scheme on endpoints protected +via Authelia. + +The specifics about this scope are discussed in the +[OAuth 2.0 Bearer Token Usage for Authorization Endpoints](oauth-2.0-bearer-token-usage.md#authorization-endpoints) +guide. + +[OAuth 2.0]: https://oauth.net/2/ +[OpenID Connect 1.0]: https://openid.net/connect/ + +[ID Token]: https://openid.net/specs/openid-connect-core-1_0.html#IDToken +[Access Token]: https://datatracker.ietf.org/doc/html/rfc6749#section-1.4 +[Refresh Token]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens + +[Claims]: https://openid.net/specs/openid-connect-core-1_0.html#Claims +[Standard Claims]: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims +[Claim]: https://openid.net/specs/openid-connect-core-1_0.html#Claims +[Offline Access]: https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess +[UserInfo Endpoint]: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + +[Standard Attributes]: ../../reference/guides/attributes.md#standard-attributes +[Custom Attributes]: ../../reference/guides/attributes.md#custom-attributes + +[Authorization Code Flow]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth +[Hybrid Flow]: https://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth + +[Token Endpoint]: https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint +[UserInfo]: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + +[RFC4122]: https://datatracker.ietf.org/doc/html/rfc4122 +[RFC8176]: https://datatracker.ietf.org/doc/html/rfc8176 + +[OAuth 2.0 Authorization Code]: https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.1 diff --git a/docs/data/languages.json b/docs/data/languages.json index e87aebd9c..e5bc05e1c 100644 --- a/docs/data/languages.json +++ b/docs/data/languages.json @@ -8,13 +8,15 @@      },      "namespaces": [          "portal", -        "settings" +        "settings", +        "consent"      ],      "languages": [          {              "display": "English",              "locale": "en",              "namespaces": [ +                "consent",                  "portal",                  "settings"              ], diff --git a/docs/static/schemas/v4.39/json-schema/configuration.json b/docs/static/schemas/v4.39/json-schema/configuration.json index 222c83387..24e8968a8 100644 --- a/docs/static/schemas/v4.39/json-schema/configuration.json +++ b/docs/static/schemas/v4.39/json-schema/configuration.json @@ -1305,6 +1305,26 @@            "title": "Lifespans",            "description": "Token lifespans configuration."          }, +        "claims_policies": { +          "patternProperties": { +            ".*": { +              "$ref": "#/$defs/IdentityProvidersOpenIDConnectClaimsPolicy" +            } +          }, +          "type": "object", +          "title": "Claims Policies", +          "description": "The dictionary of claims policies which can be applied to clients." +        }, +        "scopes": { +          "patternProperties": { +            ".*": { +              "$ref": "#/$defs/IdentityProvidersOpenIDConnectScope" +            } +          }, +          "type": "object", +          "title": "Scopes", +          "description": "List of custom scopes." +        },          "issuer_certificate_chain": {            "$ref": "#/$defs/X509CertificateChain",            "title": "Issuer Certificate Chain", @@ -1362,6 +1382,38 @@        "type": "object",        "description": "IdentityProvidersOpenIDConnectCORS represents an OpenID Connect 1.0 CORS config."      }, +    "IdentityProvidersOpenIDConnectClaimsPolicy": { +      "properties": { +        "id_token": { +          "items": { +            "type": "string" +          }, +          "type": "array", +          "title": "ID Token", +          "description": "The list of claims to automatically apply to an ID Token in addition to the specified ID Token Claims." +        }, +        "access_token": { +          "items": { +            "type": "string" +          }, +          "type": "array", +          "title": "Access Token", +          "description": "The list of claims to automatically apply to an Access Token in addition to the specified Access Token Claims." +        }, +        "custom_claims": { +          "patternProperties": { +            ".*": { +              "$ref": "#/$defs/IdentityProvidersOpenIDConnectCustomClaim" +            } +          }, +          "type": "object", +          "title": "Custom Claims", +          "description": "The custom claims available in this policy in addition to the Standard Claims." +        } +      }, +      "additionalProperties": false, +      "type": "object" +    },      "IdentityProvidersOpenIDConnectClient": {        "properties": {          "client_id": { @@ -1489,6 +1541,11 @@            "title": "Lifespan Name",            "description": "The name of the custom lifespan to utilize for this client."          }, +        "claims_policy": { +          "type": "string", +          "title": "Claims Policy", +          "description": "The claims policy to apply to this client." +        },          "requested_audience_mode": {            "type": "string",            "enum": [ @@ -1749,6 +1806,17 @@          }        ]      }, +    "IdentityProvidersOpenIDConnectCustomClaim": { +      "properties": { +        "attribute": { +          "type": "string", +          "title": "Attribute", +          "description": "The attribute that populates this claim." +        } +      }, +      "additionalProperties": false, +      "type": "object" +    },      "IdentityProvidersOpenIDConnectLifespan": {        "properties": {          "access_token": { @@ -2060,6 +2128,20 @@        "type": "object",        "description": "IdentityProvidersOpenIDConnectPolicyRule configuration for OpenID Connect 1.0 authorization policies rules."      }, +    "IdentityProvidersOpenIDConnectScope": { +      "properties": { +        "claims": { +          "items": { +            "type": "string" +          }, +          "type": "array", +          "title": "Claims", +          "description": "The list of claims that this scope includes. When this scope is used by a client the clients claim policy must satisfy every claim." +        } +      }, +      "additionalProperties": false, +      "type": "object" +    },      "IdentityValidation": {        "properties": {          "reset_password": { diff --git a/internal/configuration/provider_test.go b/internal/configuration/provider_test.go index 492082716..ebdb9850a 100644 --- a/internal/configuration/provider_test.go +++ b/internal/configuration/provider_test.go @@ -746,6 +746,60 @@ func TestShouldDisableOIDCEntropy(t *testing.T) {  	assert.Equal(t, -1, config.IdentityProviders.OIDC.MinimumParameterEntropy)  } +func TestShouldHandleOIDCClaims(t *testing.T) { +	val := schema.NewStructValidator() +	keys, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config_oidc_claims.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + +	assert.NoError(t, err) + +	validator.ValidateKeys(keys, GetMultiKeyMappedDeprecationKeys(), DefaultEnvPrefix, val) + +	require.Len(t, val.Errors(), 0) + +	val.Clear() + +	validator.ValidateIdentityProviders(validator.NewValidateCtx(), config, val) + +	require.Len(t, val.Errors(), 2) +	require.Len(t, val.Warnings(), 1) + +	assert.Regexp(t, regexp.MustCompile(`^identity_providers: oidc: jwks: key #1 with key id 'keya': option 'certificate_chain' produced an error during validation of the chain: certificate #1 in chain is invalid after 1713180174 but the time is \d+$`), val.Errors()[0].Error()) +	assert.Regexp(t, regexp.MustCompile(`^identity_providers: oidc: jwks: key #2 with key id 'ec521': option 'certificate_chain' produced an error during validation of the chain: certificate #1 in chain is invalid after 1713180101 but the time is \d+$`), val.Errors()[1].Error()) +	assert.EqualError(t, val.Warnings()[0], "identity_providers: oidc: clients: client 'abc': option 'client_secret' is plaintext but for clients not using the 'token_endpoint_auth_method' of 'client_secret_jwt' it should be a hashed value as plaintext values are deprecated with the exception of 'client_secret_jwt' and will be removed in the near future") + +	require.Len(t, config.IdentityProviders.OIDC.JSONWebKeys, 3) +	require.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[0].Key) +	require.IsType(t, &rsa.PrivateKey{}, config.IdentityProviders.OIDC.JSONWebKeys[0].Key) +	assert.Equal(t, "sig", config.IdentityProviders.OIDC.JSONWebKeys[0].Use) +	assert.Equal(t, "RS256", config.IdentityProviders.OIDC.JSONWebKeys[0].Algorithm) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[0].Key.(*rsa.PrivateKey).D) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[0].Key.(*rsa.PrivateKey).N) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[0].Key.(*rsa.PrivateKey).E) +	assert.Equal(t, 256, config.IdentityProviders.OIDC.JSONWebKeys[0].Key.(*rsa.PrivateKey).PublicKey.Size()) +	require.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[0].CertificateChain) +	assert.True(t, config.IdentityProviders.OIDC.JSONWebKeys[0].CertificateChain.HasCertificates()) + +	require.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[1].Key) +	require.IsType(t, &ecdsa.PrivateKey{}, config.IdentityProviders.OIDC.JSONWebKeys[1].Key) +	assert.Equal(t, "sig", config.IdentityProviders.OIDC.JSONWebKeys[1].Use) +	assert.Equal(t, "ES512", config.IdentityProviders.OIDC.JSONWebKeys[1].Algorithm) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[1].Key.(*ecdsa.PrivateKey).D) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[1].Key.(*ecdsa.PrivateKey).Y) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[1].Key.(*ecdsa.PrivateKey).X) +	assert.Equal(t, elliptic.P521(), config.IdentityProviders.OIDC.JSONWebKeys[1].Key.(*ecdsa.PrivateKey).Curve) +	require.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[1].CertificateChain) +	assert.True(t, config.IdentityProviders.OIDC.JSONWebKeys[1].CertificateChain.HasCertificates()) + +	require.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[2].Key) +	assert.Equal(t, "sig", config.IdentityProviders.OIDC.JSONWebKeys[2].Use) +	assert.Equal(t, "RS256", config.IdentityProviders.OIDC.JSONWebKeys[2].Algorithm) +	require.IsType(t, &rsa.PrivateKey{}, config.IdentityProviders.OIDC.JSONWebKeys[2].Key) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[2].Key.(*rsa.PrivateKey).D) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[2].Key.(*rsa.PrivateKey).N) +	assert.NotNil(t, config.IdentityProviders.OIDC.JSONWebKeys[2].Key.(*rsa.PrivateKey).E) +	assert.Equal(t, 512, config.IdentityProviders.OIDC.JSONWebKeys[2].Key.(*rsa.PrivateKey).PublicKey.Size()) +} +  func TestShouldDisableOIDCModern(t *testing.T) {  	val := schema.NewStructValidator()  	keys, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config_oidc_modern.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) diff --git a/internal/configuration/schema/identity_providers.go b/internal/configuration/schema/identity_providers.go index 2c44ea027..d2583fca2 100644 --- a/internal/configuration/schema/identity_providers.go +++ b/internal/configuration/schema/identity_providers.go @@ -34,8 +34,10 @@ type IdentityProvidersOpenIDConnect struct {  	Clients []IdentityProvidersOpenIDConnectClient `koanf:"clients" json:"clients" jsonschema:"title=Clients" jsonschema_description:"OpenID Connect 1.0 clients registry."` -	AuthorizationPolicies map[string]IdentityProvidersOpenIDConnectPolicy `koanf:"authorization_policies" json:"authorization_policies" jsonschema:"title=Authorization Policies" jsonschema_description:"Custom client authorization policies."` -	Lifespans             IdentityProvidersOpenIDConnectLifespans         `koanf:"lifespans" json:"lifespans" jsonschema:"title=Lifespans" jsonschema_description:"Token lifespans configuration."` +	AuthorizationPolicies map[string]IdentityProvidersOpenIDConnectPolicy       `koanf:"authorization_policies" json:"authorization_policies" jsonschema:"title=Authorization Policies" jsonschema_description:"Custom client authorization policies."` +	Lifespans             IdentityProvidersOpenIDConnectLifespans               `koanf:"lifespans" json:"lifespans" jsonschema:"title=Lifespans" jsonschema_description:"Token lifespans configuration."` +	ClaimsPolicies        map[string]IdentityProvidersOpenIDConnectClaimsPolicy `koanf:"claims_policies" json:"claims_policies" jsonschema:"title=Claims Policies" jsonschema_description:"The dictionary of claims policies which can be applied to clients."` +	Scopes                map[string]IdentityProvidersOpenIDConnectScope        `koanf:"scopes" json:"scopes" jsonschema:"title=Scopes" jsonschema_description:"List of custom scopes."`  	Discovery IdentityProvidersOpenIDConnectDiscovery `json:"-"` // MetaData value. Not configurable by users. @@ -43,6 +45,21 @@ type IdentityProvidersOpenIDConnect struct {  	IssuerPrivateKey       *rsa.PrivateKey      `koanf:"issuer_private_key" json:"issuer_private_key" jsonschema:"title=Issuer Private Key,deprecated" jsonschema_description:"The Issuer Private Key with an RSA Private Key used to sign ID Tokens."`  } +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."` + +	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."` +} + +type IdentityProvidersOpenIDConnectCustomClaim struct { +	Attribute string `koanf:"attribute" json:"attribute" jsonschema:"title=Attribute" jsonschema_description:"The attribute that populates this claim."` +} + +type IdentityProvidersOpenIDConnectScope struct { +	Claims []string `koanf:"claims" json:"claims" jsonschema:"title=Claims" jsonschema_description:"The list of claims that this scope includes. When this scope is used by a client the clients claim policy must satisfy every claim."` +} +  // IdentityProvidersOpenIDConnectPolicy configuration for OpenID Connect 1.0 authorization policies.  type IdentityProvidersOpenIDConnectPolicy struct {  	DefaultPolicy string `koanf:"default_policy" json:"default_policy" jsonschema:"enum=one_factor,enum=two_factor,enum=deny,title=Default Policy" jsonschema_description:"The default policy action for this policy."` @@ -59,6 +76,8 @@ type IdentityProvidersOpenIDConnectPolicyRule struct {  // IdentityProvidersOpenIDConnectDiscovery is information discovered during validation reused for the discovery handlers.  type IdentityProvidersOpenIDConnectDiscovery struct { +	Claims                      []string +	Scopes                      []string  	AuthorizationPolicies       []string  	Lifespans                   []string  	DefaultKeyIDs               map[string]string @@ -128,6 +147,7 @@ type IdentityProvidersOpenIDConnectClient struct {  	AuthorizationPolicy string `koanf:"authorization_policy" json:"authorization_policy" jsonschema:"title=Authorization Policy" jsonschema_description:"The Authorization Policy to apply to this client."`  	Lifespan            string `koanf:"lifespan" json:"lifespan" jsonschema:"title=Lifespan Name" jsonschema_description:"The name of the custom lifespan to utilize for this client."` +	ClaimsPolicy        string `koanf:"claims_policy" json:"claims_policy" jsonschema:"title=Claims Policy" jsonschema_description:"The claims policy to apply to this client."`  	RequestedAudienceMode        string         `koanf:"requested_audience_mode" json:"requested_audience_mode" jsonschema:"enum=explicit,enum=implicit,title=Requested Audience Mode" jsonschema_description:"The Requested Audience Mode used for this client."`  	ConsentMode                  string         `koanf:"consent_mode" json:"consent_mode" jsonschema:"enum=auto,enum=explicit,enum=implicit,enum=pre-configured,title=Consent Mode" jsonschema_description:"The Consent Mode used for this client."` @@ -151,8 +171,9 @@ type IdentityProvidersOpenIDConnectClient struct {  	RequestObjectSigningAlg          string `koanf:"request_object_signing_alg" json:"request_object_signing_alg" jsonschema:"enum=RS256,enum=RS384,enum=RS512,enum=ES256,enum=ES384,enum=ES512,enum=PS256,enum=PS384,enum=PS512,title=Request Object Signing Algorithm" jsonschema_description:"The Request Object Signing Algorithm the provider accepts for this client."`  	TokenEndpointAuthSigningAlg      string `koanf:"token_endpoint_auth_signing_alg" json:"token_endpoint_auth_signing_alg" jsonschema:"enum=HS256,enum=HS384,enum=HS512,enum=RS256,enum=RS384,enum=RS512,enum=ES256,enum=ES384,enum=ES512,enum=PS256,enum=PS384,enum=PS512,title=Token Endpoint Auth Signing Algorithm" jsonschema_description:"The Token Endpoint Auth Signing Algorithm the provider accepts for this client."` -	TokenEndpointAuthMethod            string `koanf:"token_endpoint_auth_method" json:"token_endpoint_auth_method" jsonschema:"enum=none,enum=client_secret_post,enum=client_secret_basic,enum=private_key_jwt,enum=client_secret_jwt,title=Token Endpoint Auth Method" jsonschema_description:"The Token Endpoint Auth Method enforced by the provider for this client."` -	AllowMultipleAuthenticationMethods bool   `koanf:"allow_multiple_auth_methods" json:"allow_multiple_auth_methods" jsonschema:"title=Allow Multiple Authentication Methods" jsonschema_description:"Permits this registered client to accept misbehaving clients which use a broad authentication approach. This is not standards complaint, use at your own security risk."` +	TokenEndpointAuthMethod string `koanf:"token_endpoint_auth_method" json:"token_endpoint_auth_method" jsonschema:"enum=none,enum=client_secret_post,enum=client_secret_basic,enum=private_key_jwt,enum=client_secret_jwt,title=Token Endpoint Auth Method" jsonschema_description:"The Token Endpoint Auth Method enforced by the provider for this client."` + +	AllowMultipleAuthenticationMethods bool `koanf:"allow_multiple_auth_methods" json:"allow_multiple_auth_methods" jsonschema:"title=Allow Multiple Authentication Methods" jsonschema_description:"Permits this registered client to accept misbehaving clients which use a broad authentication approach. This is not standards complaint, use at your own security risk."`  	JSONWebKeysURI *url.URL `koanf:"jwks_uri" json:"jwks_uri" jsonschema:"title=JSON Web Keys URI" jsonschema_description:"URI of the JWKS endpoint which contains the Public Keys used to validate request objects and the 'private_key_jwt' client authentication method for this client."`  	JSONWebKeys    []JWK    `koanf:"jwks" json:"jwks" jsonschema:"title=JSON Web Keys" jsonschema_description:"List of arbitrary Public Keys used to validate request objects and the 'private_key_jwt' client authentication method for this client."` diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index 02dbc5c8f..36efe9183 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -48,6 +48,7 @@ var Keys = []string{  	"identity_providers.oidc.clients[].response_modes",  	"identity_providers.oidc.clients[].authorization_policy",  	"identity_providers.oidc.clients[].lifespan", +	"identity_providers.oidc.clients[].claims_policy",  	"identity_providers.oidc.clients[].requested_audience_mode",  	"identity_providers.oidc.clients[].consent_mode",  	"identity_providers.oidc.clients[].pre_configured_consent_duration", @@ -76,6 +77,7 @@ var Keys = []string{  	"identity_providers.oidc.clients[].jwks[].key",  	"identity_providers.oidc.clients[].jwks[].certificate_chain",  	"identity_providers.oidc.clients[]", +	"identity_providers.oidc.authorization_policies.*",  	"identity_providers.oidc.authorization_policies",  	"identity_providers.oidc.authorization_policies.*.default_policy",  	"identity_providers.oidc.authorization_policies.*.rules", @@ -87,6 +89,7 @@ var Keys = []string{  	"identity_providers.oidc.lifespans.id_token",  	"identity_providers.oidc.lifespans.refresh_token",  	"identity_providers.oidc.lifespans.jwt_secured_authorization", +	"identity_providers.oidc.lifespans.custom.*",  	"identity_providers.oidc.lifespans.custom",  	"identity_providers.oidc.lifespans.custom.*.access_token",  	"identity_providers.oidc.lifespans.custom.*.authorize_code", @@ -112,6 +115,16 @@ var Keys = []string{  	"identity_providers.oidc.lifespans.custom.*.grants.jwt_bearer.authorize_code",  	"identity_providers.oidc.lifespans.custom.*.grants.jwt_bearer.id_token",  	"identity_providers.oidc.lifespans.custom.*.grants.jwt_bearer.refresh_token", +	"identity_providers.oidc.claims_policies.*", +	"identity_providers.oidc.claims_policies", +	"identity_providers.oidc.claims_policies.*.id_token", +	"identity_providers.oidc.claims_policies.*.access_token", +	"identity_providers.oidc.claims_policies.*.custom_claims.*", +	"identity_providers.oidc.claims_policies.*.custom_claims", +	"identity_providers.oidc.claims_policies.*.custom_claims.*.attribute", +	"identity_providers.oidc.scopes.*", +	"identity_providers.oidc.scopes", +	"identity_providers.oidc.scopes.*.claims",  	"identity_providers.oidc",  	"identity_providers.oidc.issuer_certificate_chain",  	"identity_providers.oidc.issuer_private_key", @@ -147,6 +160,7 @@ var Keys = []string{  	"authentication_backend.file.password.salt_length",  	"authentication_backend.file.search.email",  	"authentication_backend.file.search.case_insensitive", +	"authentication_backend.file.extra_attributes.*",  	"authentication_backend.file.extra_attributes",  	"authentication_backend.file.extra_attributes.*.multi_valued",  	"authentication_backend.file.extra_attributes.*.value_type", @@ -190,6 +204,7 @@ var Keys = []string{  	"authentication_backend.ldap.attributes.mail",  	"authentication_backend.ldap.attributes.member_of",  	"authentication_backend.ldap.attributes.group_name", +	"authentication_backend.ldap.attributes.extra.*",  	"authentication_backend.ldap.attributes.extra",  	"authentication_backend.ldap.attributes.extra.*.name",  	"authentication_backend.ldap.attributes.extra.*.multi_valued", @@ -341,6 +356,7 @@ var Keys = []string{  	"server.headers.csp_template",  	"server.endpoints.enable_pprof",  	"server.endpoints.enable_expvars", +	"server.endpoints.authz.*",  	"server.endpoints.authz",  	"server.endpoints.authz.*.implementation",  	"server.endpoints.authz.*.authn_strategies", @@ -384,7 +400,9 @@ var Keys = []string{  	"identity_validation.elevated_session.require_second_factor",  	"identity_validation.elevated_session.skip_second_factor",  	"definitions.network.*", +	"definitions.network.*",  	"definitions.network", +	"definitions.user_attributes.*",  	"definitions.user_attributes",  	"definitions.user_attributes.*.expression",  	"default_redirection_url", diff --git a/internal/configuration/test_resources/config_oidc_claims.yml b/internal/configuration/test_resources/config_oidc_claims.yml new file mode 100644 index 000000000..f5fb2ea1b --- /dev/null +++ b/internal/configuration/test_resources/config_oidc_claims.yml @@ -0,0 +1,276 @@ +--- +default_redirection_url: 'https://home.example.com:8080/' + +server: +  host: 127.0.0.1 +  port: 9091 + +log: +  level: 'debug' + +totp: +  issuer: 'authelia.com' + +duo_api: +  hostname: 'api-123456789.example.com' +  integration_key: 'ABCDEF' + +authentication_backend: +  ldap: +    address: 'ldap://127.0.0.1' +    base_dn: 'dc=example,dc=com' +    additional_users_dn: 'ou=users' +    users_filter: '(&({username_attribute}={input})(objectCategory=person)(objectClass=user))' +    additional_groups_dn: 'ou=groups' +    groups_filter: '(&(member={dn})(objectClass=groupOfNames))' +    user: 'cn=admin,dc=example,dc=com' +    attributes: +      mail: 'mail' +      username: 'uid' +      group_name: 'cn' + +access_control: +  default_policy: 'deny' + +  rules: +    # Rules applied to everyone +    - domain: 'public.example.com' +      policy: 'bypass' + +    - domain: 'secure.example.com' +      policy: 'one_factor' +      # Network based rule, if not provided any network matches. +      networks: +        - '192.168.1.0/24' +    - domain: 'secure.example.com' +      policy: 'two_factor' + +    - domain: +        - 'singlefactor.example.com' +        - 'onefactor.example.com' +      policy: 'one_factor' + +    # Rules applied to 'admins' group +    - domain: 'mx2.mail.example.com' +      subject: 'group:admins' +      policy: 'deny' +    - domain: '*.example.com' +      subject: 'group:admins' +      policy: 'two_factor' + +    # Rules applied to 'dev' group +    - domain: 'dev.example.com' +      resources: +        - '^/groups/dev/.*$' +      subject: 'group:dev' +      policy: 'two_factor' + +    # Rules applied to user 'john' +    - domain: 'dev.example.com' +      resources: +        - '^/users/john/.*$' +      subject: 'user:john' +      policy: 'two_factor' + +    # Rules applied to 'dev' group and user 'john' +    - domain: 'dev.example.com' +      resources: +        - '^/deny-all.*$' +      subject: ['group:dev', 'user:john'] +      policy: 'deny' + +    # Rules applied to user 'harry' +    - domain: 'dev.example.com' +      resources: +        - '^/users/harry/.*$' +      subject: 'user:harry' +      policy: 'two_factor' + +    # Rules applied to user 'bob' +    - domain: '*.mail.example.com' +      subject: 'user:bob' +      policy: 'two_factor' +    - domain: 'dev.example.com' +      resources: +        - '^/users/bob/.*$' +      subject: 'user:bob' +      policy: 'two_factor' + +session: +  name: 'authelia_session' +  expiration: 3600000  # 1 hour +  inactivity: 300000  # 5 minutes +  domain: 'example.com' +  redis: +    host: 127.0.0.1 +    port: 6379 +    high_availability: +      sentinel_name: 'test' + +regulation: +  max_retries: 3 +  find_time: 120 +  ban_time: 300 + +storage: +  mysql: +    host: 127.0.0.1 +    port: 3306 +    database: 'authelia' +    username: 'authelia' + +notifier: +  smtp: +    username: 'test' +    host: 127.0.0.1 +    port: 1025 +    sender: admin@example.com +    disable_require_tls: true + +definitions: +  user_attributes: +    authelia_admin: +      expression: '"admin" in groups' + +identity_providers: +  oidc: +    hmac_secret: '1nb2j3kh1b23kjh1b23jh1b23j1h2b3' +    jwks: +      - key_id: keya +        key: | +          -----BEGIN RSA PRIVATE KEY----- +          MIIEpQIBAAKCAQEAs5BZdREjkceDvty5c+qBski4XXiMubVyGFLazoNumhMbgjA7 +          DoCLglCrIRcYd4Wn4CEe4KJzghIdfijDCIQ6V+wrm/KR3iMvBdkPYVC7vGXYY5kx +          WtAT5qejuxKXHQK2/jWSO6tOxFRGxA/nE4A9cm8FH6/lfz05ci1h63gAOVQpkvcj +          JMHlGqTHt93HOTBVFQpi9zpTdDKQx3yq4ttfh49vUwWBsXe640Y+soaGjTMJS8IT +          kGktwOxwRfGJ1PRHVF9FnRm7nhf53hFat/k3mbbyV8rnlmVLPqZ+KIqH5/rjYh3K +          Rr71WAptFnHtoiT2SNfwDh+8iqo3QlWtW24iAwIDAQABAoIBAQCLKVkbMEA3z79b +          4SZdHqaLbG5uCmpN1sBo93WaTSQfhqVwHT73u0njoe8ugv60SsJTIngSsfQBH1b6 +          Gk8kv42T7HXTs4e299+Oka2oxu/oT6oHbodgkRiLTurGpd61XhBCLXR6iAZQg9wg +          QQ7d/yogEMiQyTp8hQ+LXH6iBetugW0l0Uz2pbJNi4c4qqXm5BYoQaJ0RjqQI5C5 +          5ZPiX/1yn3bWbJRhSK5FfnEdO/3LclfQMvMOaipH4CXOjYcFSxVP1vVL6Jli92+j +          tVApsnSZiEZ3kB4jRqDZnV9xQhfTXVyfopCNL1a3LkbE171GStd5eib7ESydTik7 +          DhFqTdpZAoGBAOgEPAmFKi9z40umcN27+dd6CAXfjy2dqkuM8hGshQiGnH9bAAl+ +          hhr3u5AvbxZ/qsD0KAAEReD3cY0OFTPfl2qD0nsleqUt6N6gM+j6596jVNnOlW9A +          0y0Lssobh8DrvXoDTBCL1wdcQXknoyUm8lkpVhSm6PmLFAS2lnBFJBJvAoGBAMYg +          FGeSQ0kRWmmVnfibcHqHerD5FK6AvciYg/ycjKUA9Fde0gon1v/M1TWAhT6mqjjv +          uSX2c3eEpzAjoJxd5bPgxfEWIM6ygn1N5fka1re5d5pHOAMJB9AAz3A3PZJP8ANt +          ES51dO0rYAuKdBuza9eSyj9/JPDh7QdE47ZvP6OtAoGBAI4aAddm2uaDWOP9hcUY +          mzXRBNbsDJpIpYNuSNhwTG5jW7hYuNYXyvT7Y8I0expRiPhy0YjpFQ9rHf3hcTT7 +          LZbMM/6+frZqPuUTQ5ffDGJ8sLxR3Y5tKqm9L3y/jc6n073GBTFhJIragzM8Bpz7 +          lJTtT06Ix8oG13Tni44pmqU7AoGBAJPS56aHSNDBs9XHnkAZqgiiAPb+QWIaCIAc +          242lOIL8fVKbGtgc9ZuSNxpeNAyUybkFk/0xLuHkBeIzEujYXkSh1s6UlhHiut3H +          O2lrjv0x0n032iDZogyeLigp7zS1k/zaadFiLcWvcU/rE8p/Sl1j1qcdtHBOAU5F +          Jim+Q5tZAoGAbNaUs05FPastfdmZCp5Pj1sbnRZdwUZH1AeZuhuSfbS6aA/wB3VA +          i/LTu4Z3iR0M0p8HeNy/YBKl0MrRc0nE/UkV66kOQH3az/N39i2KsTBzMsvlByWd +          ofwOgCjgkIviNDIXikLBEWZFwr/4mQkN91YoFY37pteS3sVYQpVbQeA= +          -----END RSA PRIVATE KEY----- +        certificate_chain: | +          -----BEGIN CERTIFICATE----- +          MIIC5jCCAc6gAwIBAgIRAMMH7qhte0VDXdnbHMy43dUwDQYJKoZIhvcNAQELBQAw +          EzERMA8GA1UEChMIQXV0aGVsaWEwHhcNMjMwNDE2MTEyMjU0WhcNMjQwNDE1MTEy +          MjU0WjATMREwDwYDVQQKEwhBdXRoZWxpYTCCASIwDQYJKoZIhvcNAQEBBQADggEP +          ADCCAQoCggEBALOQWXURI5HHg77cuXPqgbJIuF14jLm1chhS2s6DbpoTG4IwOw6A +          i4JQqyEXGHeFp+AhHuCic4ISHX4owwiEOlfsK5vykd4jLwXZD2FQu7xl2GOZMVrQ +          E+ano7sSlx0Ctv41kjurTsRURsQP5xOAPXJvBR+v5X89OXItYet4ADlUKZL3IyTB +          5Rqkx7fdxzkwVRUKYvc6U3QykMd8quLbX4ePb1MFgbF3uuNGPrKGho0zCUvCE5Bp +          LcDscEXxidT0R1RfRZ0Zu54X+d4RWrf5N5m28lfK55ZlSz6mfiiKh+f642Idyka+ +          9VgKbRZx7aIk9kjX8A4fvIqqN0JVrVtuIgMCAwEAAaM1MDMwDgYDVR0PAQH/BAQD +          AgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcN +          AQELBQADggEBAKT2CQNx/JlutnsMHYBoOLfr8Vpz3/PTx0rAgxptgp1CDkTBxIah +          7rmGuHX0qDKbqINr4DmFhkKeIPSMux11xzW6MW83ZN8WZCTkAiPPIGTNGWccJONl +          lpX5dIPdpXGoXA7T31Gto3FPmsgxQ2jK/mpok20J6EFkkpUuXxSxjwO1zd56iMxQ +          FuSLo8J/uI/T4aj7Wrk6fFI5z7gjP8BjVFAsYkTYUhkLatnbuQstx+R2p7hjv50G +          weBOw5YW8JWLRRJ2A5FBJsZekiNdpIa+CmH7v0SICAHaTKdR3RVgQfa8zvbzW/d8 +          qXsSjjBkmEkKUFoFi/fxTQuqseQC0h+P5N8= +          -----END CERTIFICATE----- +      - key_id: ec521 +        key: | +          -----BEGIN EC PRIVATE KEY----- +          MIHcAgEBBEIA5s1+7OZClSDG3Ro7FFJjR2J6cBFimlR/sNcZ4ljFjDaef4vNw2DU +          Eq1x5gkj888I1/BXV+/KVc+dtYDGKeGSxvagBwYFK4EEACOhgYkDgYYABADXFb5h +          KymYeOH8Em1VJvOsc9mUi6Gr0AAiseu5G0HofN+GzxD7GBDAE9plkRhd8QfmuwZy +          S0rUlTXhvZMARuujnABxJ7FnPp81osndv/vk9ujUTZsK0UPaLJ189NuR6VwImUQK +          c/xWqI9AC99VchRw6fw7smpn6lCmVkHNRJFL1Bs4iA== +          -----END EC PRIVATE KEY----- +        certificate_chain: | +          -----BEGIN CERTIFICATE----- +          MIIB4jCCAUOgAwIBAgIRAKhDsoaXc69n4uH0CB31XfswCgYIKoZIzj0EAwIwEzER +          MA8GA1UEChMIQXV0aGVsaWEwHhcNMjMwNDE2MTEyMTQxWhcNMjQwNDE1MTEyMTQx +          WjATMREwDwYDVQQKEwhBdXRoZWxpYTCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAE +          ANcVvmErKZh44fwSbVUm86xz2ZSLoavQACKx67kbQeh834bPEPsYEMAT2mWRGF3x +          B+a7BnJLStSVNeG9kwBG66OcAHEnsWc+nzWiyd2/++T26NRNmwrRQ9osnXz025Hp +          XAiZRApz/Faoj0AL31VyFHDp/DuyamfqUKZWQc1EkUvUGziIozUwMzAOBgNVHQ8B +          Af8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAKBggq +          hkjOPQQDAgOBjAAwgYgCQgCBXWYXKn7aANz5JC8sXLJSOkNMF6vbd/T96KoLSBT+ +          +l6KKwKg5evolAKync1ksGAzNidFKqjhwG4BZj10YnowPQJCAWjb/3KeRN1RmGOc +          abG/6Y0USqC3LUb/ZrtVwRYvQYZYi1R7OJx7cTJcVy1yBwfJa5IcPJwjEiQI3CXV +          3AGOvhz+ +          -----END CERTIFICATE----- +      - key_id: example +        key: | +          -----BEGIN PRIVATE KEY----- +          MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCGMIKnLKo+wn6P +          dMrMHOfeCtImn7X25dwP+XSVY6rN0fgAJWVyO7lJg2B1X4JHS+Ea1ihQupHR6ko/ +          0mcv62cMMql/b3BD8LExD8dfK/STGhUFc2DvVwoepiohGRoQqesl8Xwon4be4jc3 +          /h7baxF3DxRT43DJHAswa0tEQYKbBoigzTj2pkwvfjMH50eapgRBACUFloi+OSRB +          zDsLj/g7FN/6jsZI0oKkvtsXj9SeEUFj5aSGBHv+oz9DCPbni7hbSdNf4h6cTJf/ +          bs4i/oP70rojzUghmXg2lx+QotoGiy+GMOG8a+SLa4niy1qXg83iLNaRPR9C1bJF +          Gub4YVyjgxujPB9i7zVqxzimDqTGLqCJ+O+1zP9EupuXa6HWRwxS7BhDrjT97AQ/ +          hyXGfYqvw4pGw1ncfh73kf4+okJ01txCtdAk0O7SgjmNbs7BEXHgAABT1RvGlBiL +          KihsSEOMZ/o/MCck/qzfwraAQ4L6o56dioBTGd/8lLEzi7z4XXbl925D7ThCR/ba +          h6hj8Fi4+sisJ5vKrt9p0gxZXBtUCt7LnzpFC7iPjqkVjS/T0aide1CvkJR+t9Jd +          nRXtBEWiQIhj3NoqtlLHd2StNFdXRtePJCiH1h/yDdk7kkakCBi3z5mTQUCF3tss +          gSiasD171GwZFqeyRysRmN29XmWRLQIDAQABAoICACd1zHXspIOgHieBaMFtHqIk +          /HdvL8tS/MuVx7rGfEvaGtuwI4zcEziS8aKSW3Ur0x5ZK7HRq1/XTc5GySFGUB8+ +          Jqna02CnPvP2d+J8wocffF8F9tNq/QbWRj1FbGzKCuQ1hxVLFBYm36YPjHNpoNEq +          Fgg1Mc1x+bhbVN7VhNqhqTvgHYgqjuzIZ6lDUcMgXs/egwSJp9yIYSkYLaTQyWZW +          VScRJS30+YYIudTL7vIskNYXibH/T5cp2kiUMkcUxznRE992VxoPTANJSkTwI0/C +          QAqXK0b2ImurNRULTqHt/COx7C/Eaoi1Lu8KbbFwiPKhzNcY6kB1Jt87cgBIYT0n +          JQiTmj7rWojfwMKd62VNIVoQHHKgyjuWC9Tr6iEPTPWbdpI0NJ7Peno+MpwgxEY4 +          g5odiEywDfIfe+yxotldL40jGfKXbJnLjC86oYMA/avoR995TDOEjmp4z7ZU7jVk +          wEt+ZOyeNscYIcPfXMwNo586v5+O8wo4BK3uPL3fGUU6GsJfal8xtyFOawUr+/vF +          Mh+fBlbw24fpLiGincxQeUG8e5ZP3veic1XAu9WFMxOI8rT7E4timKcQSTCnRqFC +          qnpIp5b5gxmFr09ciShSOfEG27YxIvTqakQAuxC6KkoliOkZdi1d1SvhYiMq3r86 +          l+UsAjPerW/7C5oeR4LhAoIBAQC7gpRNYSJ1CDQaUVbcuGu9c46EWz2CJGvDdEtP +          DMU++aL6QA7r4hfZGT+PpqHbkJiik9SvgU2i8NCyfyK3K99z6zCIQFU6Fq84MGC5 +          b8fsID1GeUKLgAxJ1keJ0jsPkswjUf0sTbcyKHuVCeTEcaPBGfIkeJZxgydr/Bwp +          HUcw7lCdQyp3PNnh56AJpNzOLQBQsGN5IXREkM06sNAPh6MixbsAzrtUnBZDllMW +          8ptNBtSshRxdWqtGvnk7yzI4ed+85wRTX/3rx9qCX3cH88+14WYXgYv5+oR9sLv5 +          3naxZm+lL+SlMO8pZfvn1bvxdKgbxNvelO/942MvJV+dwl7HAoIBAQC3NBt2uprx +          fxcVV+wQeaWsR1jzskt74t2jNZqlrkrSikewpYwTGm4mIqlZaVPFW97Za4SozUOd +          Fa5mKgn9zCfuCNgezXohpCgRvZTCwD1xZd4n49fH6Y7sL5LpofkM2P7tVeWUQyAu +          c4fK8JDaxKy3lxhziqDwzZFRvdUkNXnB2gibvGoyHrUR6tg6DQtYI8EmdOLBUH6z +          mWoNIPH2nQ6NRVlxSqn5kKGoBLrBqyAQDEFeYwjeT4ZsB4r+/ZEGyMb10ajSCJRI +          H8RaLTUqeBTAbvGsT3VCks3FTZtl1e7Ik2gnm/cNkJ0jTiqqxyxcWs/Fhfr2TTVB +          t7StJyKkdGxrAoIBAQCLrXqxpYDYcxL049BzvMEtMA5i0+CeuGi6AUA0E4w/HBBm +          oB89YX9oTiGF7Ze2iiDZQov1bLmbfg3IWWJP1lOu8uyFIn8aNVmy3n6+w+DGKUHb +          5GpIpksBGajSppMuR6jfSqzwOrgTmgps/CC4oPpd1ukEd/uBdTSBgRytF803St8s +          NqNEjUS0vij95hxBTU5lzO9chSoqBT+Lz7g9SUmhlm9164mqPldrY2hjuLctCsCT +          /tghRnA905dVjkjLvyWGfwQ+40uwPoCsC1cMynbYwp2dNvhBb3cQdf2g5TG/dlIG +          WAXXc/tD+F9M1G8bL04m2V77kDCyDJeOwCVYzDhzAoIBABWRyPn9lmiAchNNOrnl +          2J+j9tEaWNDJMRcaJI8FKkIHjdE6bHeDURykUBP61qYR3vbyNbg6Eo/YEaRtpqd+ +          9eSMngViY9K6JQ2RqIYVZCaJu9IufSVIVk9wenePXmco1TrUNidyj8NoTsCR+jwH +          k945p+NSmwg+67EYDJQqx58PMJxFXqtv+hmV8MdE6eUCsKb6dAgWlhRHJ7lL+7Dx +          ZNk1JQa1p7V+VcoWZHaQ00GQb9HRDyY9brIhYTgMWprV4LbobFvuLqcfNlr3n57U +          bH0LkuCaqk+gQSHNtVIUEf0DfevL7RZnxFh3x4Y71Pk6p+O1loqRJIgMPPV1+hoq +          qukCggEAeKevvz2R6+zbsihf+cUM8mjTNPnuWD+1BV7XK/pn1p2o3CWVycGPAgTz +          rcIQZBu7Qt9Rzs2jHk+StYUU6AB4vkSqMN+Np9HQzjPoLmgiPGjqJESb/lvEDf8G +          Hewb2RAv3hjSG0cSnpP0xahPRuKpHIn0Jh156iDgEsRUwMooDzuxp1S6e4T6kfqj +          q8zZe96sXrueTsBw2FO4DNnaCS7iQTC6nnjqCmh4IDrXHoEgLolVa3sK5bIbRb8m +          7kFjn8oicHcPObwsS615tTqaF1mGnZBxqDFucpcHNzfkthn9kRpK4lRCO827zmqq +          F+yGFKHafzBYyQjrQPFjTu0FbiYywQ== +          -----END PRIVATE KEY----- +    cors: +      allowed_origins: +        - 'https://google.com' +        - 'https://example.com' +    claims_policies: +      default: +        custom_claims: +          authelia_admin: {} +    clients: +      - client_id: 'abc' +        client_secret: '123' +        consent_mode: 'explicit' +... diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 75cf261f6..4feea1f72 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -570,7 +570,9 @@ var (  var (  	validOIDCCORSEndpoints = []string{oidc.EndpointAuthorization, oidc.EndpointPushedAuthorizationRequest, oidc.EndpointToken, oidc.EndpointIntrospection, oidc.EndpointRevocation, oidc.EndpointUserinfo} -	validOIDCClientScopes                    = []string{oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeOfflineAccess, oidc.ScopeOffline, oidc.ScopeAutheliaBearerAuthz} +	validOIDCReservedClaims                  = []string{oidc.ClaimJWTID, oidc.ClaimSessionID, oidc.ClaimAuthorizedParty, oidc.ClaimClientIdentifier, oidc.ClaimScope, oidc.ClaimScopeNonStandard, oidc.ClaimIssuer, oidc.ClaimSubject, oidc.ClaimAudience, oidc.ClaimSessionID, oidc.ClaimStateHash, oidc.ClaimCodeHash, oidc.ClaimIssuedAt, oidc.ClaimUpdatedAt, oidc.ClaimRequestedAt, oidc.ClaimNotBefore, oidc.ClaimExpirationTime, oidc.ClaimAuthenticationTime, oidc.ClaimAuthenticationMethodsReference, oidc.ClaimAuthenticationContextClassReference, oidc.ClaimNonce} +	validOIDCClientClaims                    = []string{oidc.ClaimFullName, oidc.ClaimGivenName, oidc.ClaimFamilyName, oidc.ClaimMiddleName, oidc.ClaimNickname, oidc.ClaimPreferredUsername, oidc.ClaimProfile, oidc.ClaimPicture, oidc.ClaimWebsite, oidc.ClaimEmail, oidc.ClaimEmailVerified, oidc.ClaimGender, oidc.ClaimBirthdate, oidc.ClaimZoneinfo, oidc.ClaimLocale, oidc.ClaimPhoneNumber, oidc.ClaimPhoneNumberVerified, oidc.ClaimAddress, oidc.ClaimGroups, oidc.ClaimEmailAlts} +	validOIDCClientScopes                    = []string{oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeProfile, oidc.ScopeAddress, oidc.ScopePhone, oidc.ScopeGroups, oidc.ScopeOfflineAccess, oidc.ScopeOffline, oidc.ScopeAutheliaBearerAuthz}  	validOIDCClientConsentModes              = []string{auto, oidc.ClientConsentModeImplicit.String(), oidc.ClientConsentModeExplicit.String(), oidc.ClientConsentModePreConfigured.String()}  	validOIDCClientResponseModes             = []string{oidc.ResponseModeFormPost, oidc.ResponseModeQuery, oidc.ResponseModeFragment, oidc.ResponseModeJWT, oidc.ResponseModeFormPostJWT, oidc.ResponseModeQueryJWT, oidc.ResponseModeFragmentJWT}  	validOIDCClientResponseTypes             = []string{oidc.ResponseTypeAuthorizationCodeFlow, oidc.ResponseTypeImplicitFlowIDToken, oidc.ResponseTypeImplicitFlowToken, oidc.ResponseTypeImplicitFlowBoth, oidc.ResponseTypeHybridFlowIDToken, oidc.ResponseTypeHybridFlowToken, oidc.ResponseTypeHybridFlowBoth} @@ -598,6 +600,58 @@ var (  	reRFC3986Unreserved = regexp.MustCompile(`^[a-zA-Z0-9._~-]+$`)  ) +const ( +	attributeUserUsername       = "username" +	attributeUserGroups         = "groups" +	attributeUserDisplayName    = "display_name" +	attributeUserEmail          = "email" +	attributeUserEmails         = "emails" +	attributeUserGivenName      = "given_name" +	attributeUserMiddleName     = "middle_name" +	attributeUserFamilyName     = "family_name" +	attributeUserNickname       = "nickname" +	attributeUserProfile        = "profile" +	attributeUserPicture        = "picture" +	attributeUserWebsite        = "website" +	attributeUserGender         = "gender" +	attributeUserBirthdate      = "birthdate" +	attributeUserZoneInfo       = "zoneinfo" +	attributeUserLocale         = "locale" +	attributeUserPhoneNumber    = "phone_number" +	attributeUserPhoneExtension = "phone_extension" +	attributeUserStreetAddress  = "street_address" +	attributeUserLocality       = "locality" +	attributeUserRegion         = "region" +	attributeUserPostalCode     = "postal_code" +	attributeUserCountry        = "country" +) + +var validUserAttributes = []string{ +	attributeUserUsername, +	attributeUserGroups, +	attributeUserDisplayName, +	attributeUserEmail, +	attributeUserEmails, +	attributeUserGivenName, +	attributeUserMiddleName, +	attributeUserFamilyName, +	attributeUserNickname, +	attributeUserProfile, +	attributeUserPicture, +	attributeUserWebsite, +	attributeUserGender, +	attributeUserBirthdate, +	attributeUserZoneInfo, +	attributeUserLocale, +	attributeUserPhoneNumber, +	attributeUserPhoneExtension, +	attributeUserStreetAddress, +	attributeUserLocality, +	attributeUserRegion, +	attributeUserPostalCode, +	attributeUserCountry, +} +  var replacedKeys = map[string]string{  	"authentication_backend.ldap.skip_verify":         "authentication_backend.ldap.tls.skip_verify",  	"authentication_backend.ldap.minimum_tls_version": "authentication_backend.ldap.tls.minimum_version", diff --git a/internal/configuration/validator/identity_providers.go b/internal/configuration/validator/identity_providers.go index d2409c9d7..822188a88 100644 --- a/internal/configuration/validator/identity_providers.go +++ b/internal/configuration/validator/identity_providers.go @@ -8,6 +8,7 @@ import (  	"net/url"  	"sort"  	"strconv" +	"strings"  	oauthelia2 "authelia.com/provider/oauth2" @@ -26,11 +27,15 @@ func validateOIDC(ctx *ValidateCtx, config *schema.Configuration, validator *sch  		return  	} +	config.IdentityProviders.OIDC.Discovery.Scopes = append(config.IdentityProviders.OIDC.Discovery.Scopes, validOIDCClientScopes...) +  	setOIDCDefaults(config)  	validateOIDCIssuer(config.IdentityProviders.OIDC, validator)  	validateOIDCAuthorizationPolicies(config, validator)  	validateOIDCLifespans(config, validator) +	validateOIDCClaims(config, validator) +	validateOIDCScopes(config, validator)  	sort.Sort(oidc.SortedSigningAlgs(config.IdentityProviders.OIDC.Discovery.ResponseObjectSigningAlgs)) @@ -132,6 +137,152 @@ func validateOIDCLifespans(config *schema.Configuration, _ *schema.StructValidat  	}  } +func validateOIDCClaims(config *schema.Configuration, validator *schema.StructValidator) { +	for name, policy := range config.IdentityProviders.OIDC.ClaimsPolicies { +		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)) +			} + +			if !utils.IsStringInSlice(claim, config.IdentityProviders.OIDC.Discovery.Claims) { +				config.IdentityProviders.OIDC.Discovery.Claims = append(config.IdentityProviders.OIDC.Discovery.Claims, claim) +			} + +			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)) +			} +		} + +		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' is not known", name, claim)) +			} +		} + +		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' is not known", name, claim)) +			} +		} +	} +} + +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)) +		} 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)) +		} + +		if !utils.IsStringInSlice(scope, config.IdentityProviders.OIDC.Discovery.Scopes) { +			config.IdentityProviders.OIDC.Discovery.Scopes = append(config.IdentityProviders.OIDC.Discovery.Scopes, scope) +		} + +		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)) +			} +		} +	} +} + +//nolint:gocyclo +func isUserAttributeValid(name string, config *schema.Configuration) (valid bool) { +	if _, ok := config.Definitions.UserAttributes[name]; ok { +		return true +	} + +	if config.AuthenticationBackend.LDAP != nil { +		switch name { +		case attributeUserUsername, attributeUserDisplayName, attributeUserGroups, attributeUserEmail, attributeUserEmails: +			return true +		case attributeUserGivenName: +			return config.AuthenticationBackend.LDAP.Attributes.GivenName != "" +		case attributeUserMiddleName: +			return config.AuthenticationBackend.LDAP.Attributes.MiddleName != "" +		case attributeUserFamilyName: +			return config.AuthenticationBackend.LDAP.Attributes.FamilyName != "" +		case attributeUserNickname: +			return config.AuthenticationBackend.LDAP.Attributes.Nickname != "" +		case attributeUserProfile: +			return config.AuthenticationBackend.LDAP.Attributes.Profile != "" +		case attributeUserPicture: +			return config.AuthenticationBackend.LDAP.Attributes.Picture != "" +		case attributeUserWebsite: +			return config.AuthenticationBackend.LDAP.Attributes.Website != "" +		case attributeUserGender: +			return config.AuthenticationBackend.LDAP.Attributes.Gender != "" +		case attributeUserBirthdate: +			return config.AuthenticationBackend.LDAP.Attributes.Birthdate != "" +		case attributeUserZoneInfo: +			return config.AuthenticationBackend.LDAP.Attributes.ZoneInfo != "" +		case attributeUserLocale: +			return config.AuthenticationBackend.LDAP.Attributes.Locale != "" +		case attributeUserPhoneNumber: +			return config.AuthenticationBackend.LDAP.Attributes.PhoneNumber != "" +		case attributeUserPhoneExtension: +			return config.AuthenticationBackend.LDAP.Attributes.PhoneExtension != "" +		case attributeUserStreetAddress: +			return config.AuthenticationBackend.LDAP.Attributes.StreetAddress != "" +		case attributeUserLocality: +			return config.AuthenticationBackend.LDAP.Attributes.Locality != "" +		case attributeUserRegion: +			return config.AuthenticationBackend.LDAP.Attributes.Region != "" +		case attributeUserPostalCode: +			return config.AuthenticationBackend.LDAP.Attributes.PostalCode != "" +		case attributeUserCountry: +			return config.AuthenticationBackend.LDAP.Attributes.Country != "" +		default: +			if config.AuthenticationBackend.LDAP.Attributes.Extra == nil { +				return false +			} + +			for key, attr := range config.AuthenticationBackend.LDAP.Attributes.Extra { +				if attr.Name != "" { +					if attr.Name == name { +						return true +					} +				} else if key == name { +					return true +				} +			} + +			return false +		} +	} + +	if utils.IsStringInSlice(name, validUserAttributes) { +		return true +	} + +	if config.AuthenticationBackend.File == nil { +		return false +	} + +	if _, ok := config.AuthenticationBackend.File.ExtraAttributes[name]; ok { +		return true +	} + +	return false +} +  func validateOIDCIssuer(config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator) {  	switch {  	case len(config.JSONWebKeys) != 0 && (config.IssuerPrivateKey != nil || config.IssuerCertificateChain.HasCertificates()): diff --git a/internal/configuration/validator/identity_providers_test.go b/internal/configuration/validator/identity_providers_test.go index 7165a9552..dc2273681 100644 --- a/internal/configuration/validator/identity_providers_test.go +++ b/internal/configuration/validator/identity_providers_test.go @@ -446,7 +446,7 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) {  			clients: []schema.IdentityProvidersOpenIDConnectClient{  				{  					ID:                  "client-invalid-sector", -					Secret:              tOpenIDConnectPlainTextClientSecret, +					Secret:              tOpenIDConnectPBKDF2ClientSecret,  					AuthorizationPolicy: policyTwoFactor,  					RedirectURIs: []string{  						"https://google.com", @@ -459,7 +459,6 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) {  			},  			warns: []string{  				"identity_providers: oidc: clients: client 'client-invalid-sector': option 'sector_identifier_uri' with value 'example.com/path?query=abc#fragment': should be an absolute URI", -				"identity_providers: oidc: clients: client 'client-invalid-sector': option 'client_secret' is plaintext but for clients not using the 'token_endpoint_auth_method' of 'client_secret_jwt' it should be a hashed value as plaintext values are deprecated with the exception of 'client_secret_jwt' and will be removed in the near future",  				"identity_providers: oidc: clients: warnings for clients above indicate deprecated functionality and it's strongly suggested these issues are checked and fixed if they're legitimate issues or reported if they are not as in a future version these warnings will become errors",  			},  		}, @@ -468,7 +467,7 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) {  			clients: []schema.IdentityProvidersOpenIDConnectClient{  				{  					ID:                  "client-invalid-sector", -					Secret:              tOpenIDConnectPlainTextClientSecret, +					Secret:              tOpenIDConnectPBKDF2ClientSecret,  					AuthorizationPolicy: policyTwoFactor,  					RedirectURIs: []string{  						"https://google.com", @@ -479,9 +478,6 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) {  			errors: []string{  				"identity_providers: oidc: clients: client 'client-invalid-sector': option 'sector_identifier_uri' with value 'http://example.com/path?query=abc': must have the 'https' scheme but has the 'http' scheme",  			}, -			warns: []string{ -				"identity_providers: oidc: clients: client 'client-invalid-sector': option 'client_secret' is plaintext but for clients not using the 'token_endpoint_auth_method' of 'client_secret_jwt' it should be a hashed value as plaintext values are deprecated with the exception of 'client_secret_jwt' and will be removed in the near future", -			},  		},  		{  			name: "EmptySectorIdentifier", @@ -1235,7 +1231,7 @@ func TestValidateOIDCClients(t *testing.T) {  				[]string{oidc.GrantTypeAuthorizationCode},  			},  			[]string{ -				"identity_providers: oidc: clients: client 'test': option 'scopes' only expects the values 'openid', 'email', 'profile', 'groups', 'offline_access', 'offline', or 'authelia.bearer.authz' but the unknown values 'group' are present and should generally only be used if a particular client requires a scope outside of our standard scopes", +				"identity_providers: oidc: clients: client 'test': option 'scopes' only expects the values 'openid', 'email', 'profile', 'address', 'phone', 'groups', 'offline_access', 'offline', or 'authelia.bearer.authz' but the unknown values 'group' are present and should generally only be used if a particular client requires a scope outside of our standard scopes",  			},  			nil,  		}, diff --git a/internal/handlers/handler_oauth_authorization_claims.go b/internal/handlers/handler_oauth_authorization_claims.go new file mode 100644 index 000000000..53d5c387c --- /dev/null +++ b/internal/handlers/handler_oauth_authorization_claims.go @@ -0,0 +1,69 @@ +package handlers + +import ( +	"net/http" +	"net/url" + +	oauthelia2 "authelia.com/provider/oauth2" + +	"github.com/authelia/authelia/v4/internal/authentication" +	"github.com/authelia/authelia/v4/internal/middlewares" +	"github.com/authelia/authelia/v4/internal/model" +	"github.com/authelia/authelia/v4/internal/oidc" +	"github.com/authelia/authelia/v4/internal/session" +) + +func handleOAuth2AuthorizationClaims(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, _ *http.Request, flow string, userSession session.UserSession, details *authentication.UserDetailsExtended, client oidc.Client, requester oauthelia2.AuthorizeRequester, issuer *url.URL, consent *model.OAuth2ConsentSession, extra map[string]any) (requests *oidc.ClaimsRequests, handled bool) { +	var err error + +	if requester.GetRequestedScopes().Has(oidc.ScopeOpenID) { +		if requests, err = oidc.NewClaimRequests(requester.GetRequestForm()); err != nil { +			ctx.Logger.WithError(err).Errorf("%s Request with id '%s' on client with id '%s' could not be processed: error occurred parsing the claims parameter", flow, requester.GetID(), client.GetID()) + +			ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, err) + +			return nil, true +		} + +		claimsStrategy := client.GetClaimsStrategy() +		scopeStrategy := ctx.Providers.OpenIDConnect.GetScopeStrategy(ctx) + +		if err = claimsStrategy.ValidateClaimsRequests(ctx, scopeStrategy, client, requests); err != nil { +			ctx.Logger.WithError(oauthelia2.ErrorToDebugRFC6749Error(err)).Errorf("%s Request with id '%s' on client with id '%s' could not be processed: the client requested claims were not permitted.", flow, requester.GetID(), client.GetID()) + +			ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oauthelia2.ErrAccessDenied.WithHint("The requested subject was not the same subject that attempted to authorize the request.")) + +			return nil, true +		} + +		if requested, ok := requests.MatchesIssuer(issuer); !ok { +			ctx.Logger.Errorf("%s Request with id '%s' on client with id '%s' could not be processed: the client requested issuer '%s' but the issuer for the token will be '%s' instead", flow, requester.GetID(), client.GetID(), requested, issuer.String()) + +			ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oauthelia2.ErrAccessDenied.WithHint("The requested issuer was not the same issuer that attempted to authorize the request.")) + +			return nil, true +		} + +		if requested, ok := requests.MatchesSubject(consent.Subject.UUID.String()); !ok { +			ctx.Logger.Errorf("%s Request with id '%s' on client with id '%s' could not be processed: the client requested subject '%s' but the subject value for '%s' is '%s' for the '%s' sector identifier", flow, requester.GetID(), client.GetID(), requested, userSession.Username, consent.Subject.UUID, client.GetSectorIdentifierURI()) + +			ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oauthelia2.ErrAccessDenied.WithHint("The requested subject was not the same subject that attempted to authorize the request.")) + +			return nil, true +		} + +		oidc.GrantScopeAudienceConsent(requester, consent) + +		if err = claimsStrategy.PopulateIDTokenClaims(ctx, scopeStrategy, client, requester.GetGrantedScopes(), oauthelia2.Arguments(consent.GrantedClaims), requests.GetIDTokenRequests(), details, ctx.Clock.Now(), nil, extra); err != nil { +			ctx.Logger.Errorf("%s Response for Request with id '%s' on client with id '%s' could not be created: %s", flow, requester.GetID(), client.GetID(), oauthelia2.ErrorToDebugRFC6749Error(err)) + +			ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, err) + +			return nil, true +		} +	} else { +		oidc.GrantScopeAudienceConsent(requester, consent) +	} + +	return requests, false +} diff --git a/internal/handlers/handler_oidc_authorization.go b/internal/handlers/handler_oidc_authorization.go index 2099b9af1..86bb74b49 100644 --- a/internal/handlers/handler_oidc_authorization.go +++ b/internal/handlers/handler_oidc_authorization.go @@ -28,8 +28,7 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  		err       error  	) -	requester, err = ctx.Providers.OpenIDConnect.NewAuthorizeRequest(ctx, r) -	if requester == nil { +	if requester, err = ctx.Providers.OpenIDConnect.NewAuthorizeRequest(ctx, r); requester == nil {  		err = oauthelia2.ErrServerError.WithDebug("The requester was nil.")  		ctx.Logger.Errorf("Authorization Request failed with error: %s", oauthelia2.ErrorToDebugRFC6749Error(err)) @@ -81,7 +80,6 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  	var (  		issuer      *url.URL -		details     *authentication.UserDetails  		userSession session.UserSession  		consent     *model.OAuth2ConsentSession  		handled     bool @@ -119,7 +117,9 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  		return  	} -	if details, err = ctx.Providers.UserProvider.GetDetails(userSession.Username); err != nil { +	var details *authentication.UserDetailsExtended + +	if details, err = ctx.Providers.UserProvider.GetDetailsExtended(userSession.Username); err != nil {  		ctx.Logger.WithError(err).Errorf("Authorization Request with id '%s' on client with id '%s' using policy '%s' could not be processed: error occurred retrieving user details for '%s' from the backend", requester.GetID(), client.GetID(), policy.Name, userSession.Username)  		ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, oauthelia2.ErrServerError.WithHint("Could not obtain the users details.")) @@ -127,11 +127,17 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr  		return  	} -	extraClaims := oidcGrantRequests(requester, consent, details) +	var requests *oidc.ClaimsRequests + +	extra := map[string]any{} + +	if requests, handled = handleOAuth2AuthorizationClaims(ctx, rw, r, "Authorization", userSession, details, client, requester, issuer, consent, extra); handled { +		return +	}  	ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' was successfully processed, proceeding to build Authorization Response", requester.GetID(), clientID) -	session := oidc.NewSessionWithAuthorizeRequest(ctx, issuer, ctx.Providers.OpenIDConnect.KeyManager.GetKeyID(ctx, client.GetIDTokenSignedResponseKeyID(), client.GetIDTokenSignedResponseAlg()), details.Username, userSession.AuthenticationMethodRefs.MarshalRFC8176(), extraClaims, userSession.LastAuthenticatedTime(), consent, requester) +	session := oidc.NewSessionWithRequester(ctx, issuer, ctx.Providers.OpenIDConnect.KeyManager.GetKeyID(ctx, client.GetIDTokenSignedResponseKeyID(), client.GetIDTokenSignedResponseAlg()), details.Username, userSession.AuthenticationMethodRefs.MarshalRFC8176(), extra, userSession.LastAuthenticatedTime(), consent, requester, requests)  	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/handlers/handler_oidc_authorization_consent_implicit.go b/internal/handlers/handler_oidc_authorization_consent_implicit.go index 308c4fbb5..1d7ac9ef3 100644 --- a/internal/handlers/handler_oidc_authorization_consent_implicit.go +++ b/internal/handlers/handler_oidc_authorization_consent_implicit.go @@ -84,7 +84,13 @@ func handleOIDCAuthorizationConsentModeImplicitWithID(ctx *middlewares.AutheliaC  		return nil, true  	} -	consent.Grant() +	var requests *oidc.ClaimsRequests + +	if requests, err = oidc.NewClaimRequests(requester.GetRequestForm()); err == nil && requests != nil { +		consent.GrantWithClaims(requests.ToSlice()) +	} else { +		consent.Grant() +	}  	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, consent, false); err != nil {  		ctx.Logger.Errorf(logFmtErrConsentSaveSessionResponse, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err) @@ -138,7 +144,13 @@ func handleOIDCAuthorizationConsentModeImplicitWithoutID(ctx *middlewares.Authel  		ctx.Logger.WithFields(map[string]any{"requested_at": consent.RequestedAt, "authenticated_at": userSession.LastAuthenticatedTime(), "prompt": requester.GetRequestForm().Get("prompt")}).Debugf("Authorization Request with id '%s' on client with id '%s' is not being redirected for reauthentication", requester.GetID(), client.GetID())  	} -	consent.Grant() +	var requests *oidc.ClaimsRequests + +	if requests, err = oidc.NewClaimRequests(requester.GetRequestForm()); err == nil && requests != nil { +		consent.GrantWithClaims(requests.ToSlice()) +	} else { +		consent.Grant() +	}  	if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, consent, false); err != nil {  		ctx.Logger.Errorf(logFmtErrConsentSaveSessionResponse, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID, err) diff --git a/internal/handlers/handler_oidc_authorization_consent_pre_configured.go b/internal/handlers/handler_oidc_authorization_consent_pre_configured.go index 91d67d65a..2dc3fc800 100644 --- a/internal/handlers/handler_oidc_authorization_consent_pre_configured.go +++ b/internal/handlers/handler_oidc_authorization_consent_pre_configured.go @@ -98,7 +98,7 @@ func handleOIDCAuthorizationConsentModePreConfiguredWithID(ctx *middlewares.Auth  	}  	if config != nil { -		consent.Grant() +		consent.GrantWithClaims(config.GrantedClaims)  		consent.PreConfiguration = sql.NullInt64{Int64: config.ID, Valid: true} @@ -230,20 +230,50 @@ func handleOIDCAuthorizationConsentModePreConfiguredGetPreConfig(ctx *middleware  		}  	}() +	var ( +		requests *oidc.ClaimsRequests + +		serialized, signature string +	) + +	if requests, err = oidc.NewClaimRequests(requester.GetRequestForm()); err != nil { +		return nil, fmt.Errorf("error parsing claim requests: %w", err) +	} else if requests != nil { +		if serialized, signature, err = requests.Serialized(); err != nil { +			return nil, fmt.Errorf("error serializing claim requests: %w", err) +		} +	} +  	scopes, audience := requester.GetRequestedScopes(), requester.GetRequestedAudience() +	log := ctx.Logger.WithFields(map[string]any{"scopes": scopes, "claims": serialized, "audience": audience, "client_id": client.GetID()}) +  	for rows.Next() {  		if config, err = rows.Get(); err != nil {  			return nil, fmt.Errorf("error iterating rows: %w", err)  		} -		if config.HasExactGrants(scopes, audience) && config.CanConsent() { -			ctx.Logger.Debugf(logFmtDbgConsentPreConfSuccessfulLookup, requester.GetID(), client.GetID(), client.GetConsentPolicy(), client.GetID(), subject, strings.Join(requester.GetRequestedScopes(), " "), config.ID) +		if !config.CanConsent() { +			log.Debugf("Authorization Request with id '%s' on client with id '%s' using consent mode '%s' found a matching pre-configuration with id '%d' but it is revoked, expired, or otherwise can no longer provide consent", requester.GetID(), client.GetID(), client.GetConsentPolicy(), config.ID) -			return config, nil +			continue  		} -		ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' using consent mode '%s' request with scopes '%s' and audience '%s' did not match pre-configured consent with scopes '%s' and audience '%s'", requester.GetID(), client.GetID(), client.GetConsentPolicy(), strings.Join(scopes, " "), strings.Join(audience, " "), strings.Join(config.Scopes, " "), strings.Join(config.Audience, " ")) +		if !config.HasExactGrants(scopes, audience) { +			log.Debugf("Authorization Request with id '%s' on client with id '%s' using consent mode '%s' found a matching pre-configuration with id '%d' but the configuration has scopes '%s' and audience '%s' which coes not match the request", requester.GetID(), client.GetID(), client.GetConsentPolicy(), config.ID, strings.Join(config.Scopes, " "), strings.Join(config.Audience, " ")) + +			continue +		} + +		if !config.HasClaimsSignature(signature) { +			log.Debugf("Authorization Request with id '%s' on client with id '%s' using consent mode '%s' found a matching pre-configuration with id '%d' but the configuration had the requested claims '%s' which coes not match the request", requester.GetID(), client.GetID(), client.GetConsentPolicy(), config.ID, config.RequestedClaims.String) + +			continue +		} + +		log.Debugf(logFmtDbgConsentPreConfSuccessfulLookup, requester.GetID(), client.GetID(), client.GetConsentPolicy(), client.GetID(), subject, strings.Join(requester.GetRequestedScopes(), " "), config.ID) + +		return config, nil  	}  	ctx.Logger.Debugf(logFmtDbgConsentPreConfUnsuccessfulLookup, requester.GetID(), client.GetID(), client.GetConsentPolicy(), client.GetID(), subject, strings.Join(scopes, " "), strings.Join(audience, " ")) diff --git a/internal/handlers/handler_oidc_consent.go b/internal/handlers/handler_oidc_consent.go index 32931e914..4ab604844 100644 --- a/internal/handlers/handler_oidc_consent.go +++ b/internal/handlers/handler_oidc_consent.go @@ -6,8 +6,8 @@ import (  	"fmt"  	"net/url"  	"path" -	"time" +	"authelia.com/provider/oauth2"  	"github.com/google/uuid"  	"github.com/authelia/authelia/v4/internal/authorization" @@ -33,6 +33,7 @@ func OpenIDConnectConsentGET(ctx *middlewares.AutheliaCtx) {  	var (  		consent *model.OAuth2ConsentSession +		form    url.Values  		client  oidc.Client  		handled bool  	) @@ -41,12 +42,21 @@ func OpenIDConnectConsentGET(ctx *middlewares.AutheliaCtx) {  		return  	} -	if err = ctx.SetJSONBody(client.GetConsentResponseBody(consent)); err != nil { +	if form, err = handleGetFormFromConsent(ctx, consent); err != nil { +		ctx.Logger.WithError(err).Errorf("Unable to get form from consent session with id '%s': %+v", consent.ChallengeID, err) +		ctx.SetJSONError(messageOperationFailed) + +		return +	} + +	if err = ctx.SetJSONBody(client.GetConsentResponseBody(consent, form)); err != nil {  		ctx.Error(fmt.Errorf("unable to set JSON body: %w", err), "Operation failed")  	}  }  // OpenIDConnectConsentPOST handles consent responses for OpenID Connect. +// +//nolint:gocyclo  func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) {  	var (  		consentID uuid.UUID @@ -95,18 +105,44 @@ func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) {  		return  	} +	var form url.Values + +	if form, err = consent.GetForm(); err != nil { +		ctx.Logger.WithError(err).Errorf("Error occurred getting request form from consent session for user '%s' and client with id '%s'", userSession.Username, consent.ClientID) +		ctx.SetJSONError(messageOperationFailed) + +		return +	} +  	if bodyJSON.Consent { -		consent.Grant() +		consent.GrantWithClaims(bodyJSON.Claims)  		if bodyJSON.PreConfigure {  			if client.GetConsentPolicy().Mode == oidc.ClientConsentModePreConfigured {  				config := model.OAuth2ConsentPreConfig{ -					ClientID:  consent.ClientID, -					Subject:   consent.Subject.UUID, -					CreatedAt: time.Now(), -					ExpiresAt: sql.NullTime{Time: time.Now().Add(client.GetConsentPolicy().Duration), Valid: true}, -					Scopes:    consent.GrantedScopes, -					Audience:  consent.GrantedAudience, +					ClientID:      consent.ClientID, +					Subject:       consent.Subject.UUID, +					CreatedAt:     ctx.Clock.Now(), +					ExpiresAt:     sql.NullTime{Time: ctx.Clock.Now().Add(client.GetConsentPolicy().Duration), Valid: true}, +					Scopes:        consent.GrantedScopes, +					Audience:      consent.GrantedAudience, +					GrantedClaims: bodyJSON.Claims, +				} + +				var ( +					requests   *oidc.ClaimsRequests +					actualForm url.Values +				) + +				if actualForm, err = handleGetFormFromConsentForm(ctx, form); err != nil { +					ctx.Logger.WithError(err).Debug("Error occurred resolving the actual form from the consent form") +				} else if requests, err = oidc.NewClaimRequests(actualForm); err != nil { +					ctx.Logger.WithError(err).Debug("Error occurred parsing claims parameter from request form for claims signature") +				} else if config.RequestedClaims.String, config.SignatureClaims.String, err = requests.Serialized(); err != nil { +					ctx.Logger.WithError(err).Debug("Error occurred calculating claims signature") +				} else { +					config.RequestedClaims.Valid = true +					config.SignatureClaims.Valid = true  				}  				var id int64 @@ -224,3 +260,35 @@ func handleOpenIDConnectConsentGetSessionsAndClient(ctx *middlewares.AutheliaCtx  	return userSession, consent, client, false  } + +func handleGetFormFromConsent(ctx *middlewares.AutheliaCtx, consent *model.OAuth2ConsentSession) (form url.Values, err error) { +	if form, err = consent.GetForm(); err != nil { +		return nil, err +	} + +	return handleGetFormFromConsentForm(ctx, form) +} + +func handleGetFormFromConsentForm(ctx *middlewares.AutheliaCtx, original url.Values) (form url.Values, err error) { +	var requester oauth2.AuthorizeRequester + +	if requester, err = handleGetPushedAuthorizationRequesterFromForm(ctx, original); err != nil { +		return nil, err +	} else if requester != nil { +		return requester.GetRequestForm(), nil +	} + +	return form, err +} + +func handleGetPushedAuthorizationRequesterFromForm(ctx *middlewares.AutheliaCtx, form url.Values) (requester oauth2.AuthorizeRequester, err error) { +	if oidc.IsPushedAuthorizedRequestForm(form, ctx.Providers.OpenIDConnect.GetPushedAuthorizeRequestURIPrefix(ctx)) { +		if requester, err = ctx.Providers.OpenIDConnect.GetPARSession(ctx, form.Get(oidc.FormParameterRequestURI)); err != nil { +			return nil, err +		} + +		return requester, nil +	} + +	return nil, nil +} diff --git a/internal/handlers/handler_oidc_userinfo.go b/internal/handlers/handler_oidc_userinfo.go index d0d5c9d6d..b199b0b44 100644 --- a/internal/handlers/handler_oidc_userinfo.go +++ b/internal/handlers/handler_oidc_userinfo.go @@ -7,7 +7,6 @@ import (  	"time"  	oauthelia2 "authelia.com/provider/oauth2" -	"authelia.com/provider/oauth2/handler/oauth2"  	"authelia.com/provider/oauth2/token/jwt"  	"authelia.com/provider/oauth2/x/errorsx"  	"github.com/google/uuid" @@ -21,7 +20,7 @@ import (  // OpenIDConnectUserinfo handles GET/POST requests to the OpenID Connect 1.0 UserInfo endpoint.  //  // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo -func OpenIDConnectUserinfo(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, req *http.Request) { +func OpenIDConnectUserinfo(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *http.Request) {  	var (  		requestID uuid.UUID  		tokenType oauthelia2.TokenType @@ -31,75 +30,97 @@ func OpenIDConnectUserinfo(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter,  	)  	if requestID, err = uuid.NewRandom(); err != nil { -		errorsx.WriteJSONError(rw, req, oauthelia2.ErrServerError) +		errorsx.WriteJSONError(rw, r, oauthelia2.ErrServerError)  		return  	}  	oidcSession := oidc.NewSession() -	ctx.Logger.Debugf("UserInfo Request with id '%s' is being processed", requestID) +	ctx.Logger.Debugf("User Info Request with id '%s' is being processed", requestID) -	if tokenType, requester, err = ctx.Providers.OpenIDConnect.IntrospectToken(req.Context(), oauthelia2.AccessTokenFromRequest(req), oauthelia2.AccessToken, oidcSession); err != nil { -		ctx.Logger.Errorf("UserInfo Request with id '%s' failed with error: %s", requestID, oauthelia2.ErrorToDebugRFC6749Error(err)) +	if tokenType, requester, err = ctx.Providers.OpenIDConnect.IntrospectToken(r.Context(), oauthelia2.AccessTokenFromRequest(r), oauthelia2.AccessToken, oidcSession); err != nil { +		ctx.Logger.Errorf("User Info Request with id '%s' failed with error: %s", requestID, oauthelia2.ErrorToDebugRFC6749Error(err))  		if rfc := oauthelia2.ErrorToRFC6749Error(err); rfc.StatusCode() == http.StatusUnauthorized {  			rw.Header().Set(fasthttp.HeaderWWWAuthenticate, fmt.Sprintf(`Bearer %s`, oidc.RFC6750Header("", "", rfc)))  		} -		errorsx.WriteJSONError(rw, req, err) +		errorsx.WriteJSONError(rw, r, err)  		return  	} -	clientID := requester.GetClient().GetID() -  	if tokenType != oauthelia2.AccessToken { -		ctx.Logger.Errorf("UserInfo Request with id '%s' on client with id '%s' failed with error: bearer authorization failed as the token is not an access token", requestID, client.GetID()) +		ctx.Logger.Errorf("User Info Request with id '%s' on client with id '%s' failed with error: bearer authorization failed as the token is not an access token", requestID, client.GetID())  		errStr := "Only access tokens are allowed in the authorization header."  		rw.Header().Set(fasthttp.HeaderWWWAuthenticate, fmt.Sprintf(`Bearer error="invalid_token",error_description="%s"`, errStr)) -		errorsx.WriteJSONErrorCode(rw, req, http.StatusUnauthorized, errors.New(errStr)) +		errorsx.WriteJSONErrorCode(rw, r, http.StatusUnauthorized, errors.New(errStr))  		return  	} -	if client, err = ctx.Providers.OpenIDConnect.GetRegisteredClient(ctx, clientID); err != nil { -		ctx.Logger.Errorf("UserInfo Request with id '%s' on client with id '%s' failed to retrieve client configuration with error: %s", requestID, client.GetID(), oauthelia2.ErrorToDebugRFC6749Error(err)) +	if client, err = ctx.Providers.OpenIDConnect.GetRegisteredClient(ctx, requester.GetClient().GetID()); err != nil { +		ctx.Logger.Errorf("User Info Request with id '%s' on client with id '%s' failed to retrieve client configuration with error: %s", requestID, client.GetID(), oauthelia2.ErrorToDebugRFC6749Error(err)) -		errorsx.WriteJSONError(rw, req, err) +		errorsx.WriteJSONError(rw, r, err)  		return  	}  	var ( -		original map[string]any +		original      map[string]any +		requests      map[string]*oidc.ClaimRequest +		claimsGranted oauthelia2.Arguments +		userinfo      bool  	)  	switch session := requester.GetSession().(type) {  	case *oidc.Session:  		original = session.IDTokenClaims().ToMap() -	case *oauth2.JWTSession: -		original = session.JWTClaims.ToMap() +		requests = session.ClaimRequests.GetUserInfoRequests() +		userinfo = !session.ClientCredentials +		claimsGranted = session.GrantedClaims  	default: -		ctx.Logger.Errorf("UserInfo Request with id '%s' on client with id '%s' failed to handle session with type '%T'", requestID, client.GetID(), session) +		ctx.Logger.Errorf("User Info Request with id '%s' on client with id '%s' failed to handle session with type '%T'", requestID, client.GetID(), session) -		errorsx.WriteJSONError(rw, req, oauthelia2.ErrServerError.WithDebugf("Failed to handle session with type '%T'.", session)) +		errorsx.WriteJSONError(rw, r, oauthelia2.ErrServerError.WithDebugf("Failed to handle session with type '%T'.", session))  		return  	}  	claims := map[string]any{} -	oidcApplyUserInfoClaims(clientID, requester.GetGrantedScopes(), original, claims, oidcCtxDetailResolver(ctx)) +	var detailer oidc.UserDetailer + +	if detailer, err = oidcDetailerFromClaims(ctx, original); err != nil { +		if err = client.GetClaimsStrategy().PopulateClientCredentialsUserInfoClaims(ctx, client, original, claims); err != nil { +			ctx.Logger.WithError(err).Errorf("User Info Request with id '%s' on client with id '%s' failed due to an error populating claims for the client credentials flow", requestID, client.GetID()) + +			errorsx.WriteJSONError(rw, r, oauthelia2.ErrServerError.WithDebugf("Error occurred populating claims for the client credentials flow: %v.", err)) + +			return +		} + +		if userinfo { +			ctx.Logger.WithError(err).Errorf("User Info Request with id '%s' on client with id '%s' error occurred loading user information", requestID, client.GetID()) +		} +	} else if err = client.GetClaimsStrategy().PopulateUserInfoClaims(ctx, ctx.Providers.OpenIDConnect.GetScopeStrategy(ctx), client, requester.GetGrantedScopes(), claimsGranted, requests, detailer, ctx.Clock.Now(), original, claims); err != nil { +		ctx.Logger.WithError(err).Errorf("User Info Request with id '%s' on client with id '%s' failed due to an error populating claims for the standard flow", requestID, client.GetID()) + +		errorsx.WriteJSONError(rw, r, oauthelia2.ErrServerError.WithDebugf("Error occurred populating claims for the standard flow: %v.", err)) + +		return +	}  	var token string -	ctx.Logger.Tracef("UserInfo Response with id '%s' on client with id '%s' is being sent with the following claims: %+v", requestID, clientID, claims) +	ctx.Logger.Tracef("User Info Response with id '%s' on client with id '%s' is being sent with the following claims: %+v", requestID, requester.GetClient().GetID(), claims)  	switch alg := client.GetUserinfoSignedResponseAlg(); alg {  	case oidc.SigningAlgNone: -		ctx.Logger.Debugf("UserInfo Request with id '%s' on client with id '%s' is being returned unsigned as per the registered client configuration", requestID, client.GetID()) +		ctx.Logger.Debugf("User Info Request with id '%s' on client with id '%s' is being returned unsigned as per the registered client configuration", requestID, client.GetID())  		rw.Header().Set(fasthttp.HeaderContentType, "application/json; charset=utf-8")  		rw.Header().Set(fasthttp.HeaderCacheControl, "no-store") @@ -111,7 +132,7 @@ func OpenIDConnectUserinfo(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter,  		var jwk *oidc.JWK  		if jwk = ctx.Providers.OpenIDConnect.KeyManager.Get(ctx, client.GetUserinfoSignedResponseKeyID(), alg); jwk == nil { -			errorsx.WriteJSONError(rw, req, errors.WithStack(oauthelia2.ErrServerError.WithHintf("Unsupported UserInfo signing algorithm '%s'.", alg))) +			errorsx.WriteJSONError(rw, r, errors.WithStack(oauthelia2.ErrServerError.WithHintf("Unsupported UserInfo signing algorithm '%s'.", alg)))  			return  		} @@ -121,7 +142,7 @@ func OpenIDConnectUserinfo(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter,  		var jti uuid.UUID  		if jti, err = uuid.NewRandom(); err != nil { -			errorsx.WriteJSONError(rw, req, oauthelia2.ErrServerError.WithHint("Could not generate JTI.")) +			errorsx.WriteJSONError(rw, r, oauthelia2.ErrServerError.WithHint("Could not generate JTI."))  			return  		} @@ -135,8 +156,8 @@ func OpenIDConnectUserinfo(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter,  			},  		} -		if token, _, err = jwk.Strategy().Generate(req.Context(), claims, headers); err != nil { -			errorsx.WriteJSONError(rw, req, err) +		if token, _, err = jwk.Strategy().Generate(r.Context(), claims, headers); err != nil { +			errorsx.WriteJSONError(rw, r, err)  			return  		} @@ -149,5 +170,5 @@ func OpenIDConnectUserinfo(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter,  		_, _ = rw.Write([]byte(token))  	} -	ctx.Logger.Debugf("UserInfo Request with id '%s' on client with id '%s' was successfully processed", requestID, client.GetID()) +	ctx.Logger.Debugf("User Info Request with id '%s' on client with id '%s' was successfully processed", requestID, client.GetID())  } diff --git a/internal/handlers/oidc.go b/internal/handlers/oidc.go index bc035b32d..4dee5e997 100644 --- a/internal/handlers/oidc.go +++ b/internal/handlers/oidc.go @@ -1,175 +1,56 @@  package handlers  import ( -	oauthelia2 "authelia.com/provider/oauth2" +	"fmt" +  	"github.com/google/uuid"  	"github.com/authelia/authelia/v4/internal/authentication"  	"github.com/authelia/authelia/v4/internal/middlewares"  	"github.com/authelia/authelia/v4/internal/model"  	"github.com/authelia/authelia/v4/internal/oidc" -	"github.com/authelia/authelia/v4/internal/utils"  ) -func oidcGrantRequests(ar oauthelia2.AuthorizeRequester, consent *model.OAuth2ConsentSession, details oidc.UserDetailer) (extraClaims map[string]any) { -	extraClaims = map[string]any{} - -	oidcApplyScopeClaims(extraClaims, consent.GrantedScopes, details) - -	if ar != nil { -		for _, scope := range consent.GrantedScopes { -			ar.GrantScope(scope) -		} - -		for _, audience := range consent.GrantedAudience { -			ar.GrantAudience(audience) -		} -	} - -	return extraClaims -} - -func oidcApplyScopeClaims(claims map[string]any, scopes []string, detailer oidc.UserDetailer) { -	for _, scope := range scopes { -		switch scope { -		case oidc.ScopeGroups: -			claims[oidc.ClaimGroups] = detailer.GetGroups() -		case oidc.ScopeProfile: -			claims[oidc.ClaimPreferredUsername] = detailer.GetUsername() -			claims[oidc.ClaimFullName] = detailer.GetDisplayName() -		case oidc.ScopeEmail: -			if emails := detailer.GetEmails(); len(emails) != 0 { -				claims[oidc.ClaimPreferredEmail] = emails[0] -				if len(emails) > 1 { -					claims[oidc.ClaimEmailAlts] = emails[1:] -				} - -				// TODO (james-d-elliott): actually verify emails and record that information. -				claims[oidc.ClaimEmailVerified] = true -			} -		} -	} -} - -func oidcGetAudience(claims map[string]any) (audience []string, ok bool) { -	var aud any - -	if aud, ok = claims[oidc.ClaimAudience]; ok { -		switch v := aud.(type) { -		case string: -			ok = true - -			audience = []string{v} -		case []any: -			var value string - -			for _, a := range v { -				if value, ok = a.(string); !ok { -					return nil, false -				} - -				audience = append(audience, value) -			} - -			ok = true -		case []string: -			ok = true - -			audience = v -		} -	} - -	return audience, ok -} - -func oidcApplyUserInfoClaims(clientID string, scopes oauthelia2.Arguments, originalClaims, claims map[string]any, resolver oidcDetailResolver) { -	for claim, value := range originalClaims { -		switch claim { -		case oidc.ClaimJWTID, oidc.ClaimSessionID, oidc.ClaimAccessTokenHash, oidc.ClaimCodeHash, oidc.ClaimExpirationTime, oidc.ClaimNonce, oidc.ClaimStateHash: -			// Skip special OpenID Connect 1.0 Claims. -			continue -		case oidc.ClaimPreferredUsername, oidc.ClaimPreferredEmail, oidc.ClaimEmailVerified, oidc.ClaimEmailAlts, oidc.ClaimGroups, oidc.ClaimFullName: -			continue -		default: -			claims[claim] = value -		} -	} - -	audience, ok := oidcGetAudience(originalClaims) - -	if !ok || len(audience) == 0 { -		audience = []string{clientID} -	} else if !utils.IsStringInSlice(clientID, audience) { -		audience = append(audience, clientID) -	} - -	claims[oidc.ClaimAudience] = audience - -	oidcApplyUserInfoDetailsClaims(scopes, claims, resolver) -} - -func oidcApplyUserInfoDetailsClaims(scopes oauthelia2.Arguments, claims map[string]any, resolver oidcDetailResolver) { +func oidcDetailerFromClaims(ctx *middlewares.AutheliaCtx, claims map[string]any) (detailer oidc.UserDetailer, err error) {  	var ( -		detailer oidc.UserDetailer -		subject  uuid.UUID -		ok       bool -		err      error +		subject    uuid.UUID +		identifier *model.UserOpaqueIdentifier +		details    *authentication.UserDetailsExtended  	) -	if subject, ok = oidcApplyUserInfoDetailsClaimsGetSubject(scopes, claims); !ok { -		return +	if subject, err = oidcSubjectUUIDFromClaims(claims); err != nil { +		return nil, err  	} -	if detailer, err = resolver(subject); err != nil { -		return +	if identifier, err = ctx.Providers.StorageProvider.LoadUserOpaqueIdentifier(ctx, subject); err != nil { +		return nil, err  	} -	oidcApplyScopeClaims(claims, scopes, detailer) -} - -func oidcApplyUserInfoDetailsClaimsGetSubject(scopes oauthelia2.Arguments, claims map[string]any) (subject uuid.UUID, ok bool) { -	if !scopes.HasOneOf(oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeGroups) { -		return uuid.UUID{}, false +	if details, err = ctx.Providers.UserProvider.GetDetailsExtended(identifier.Username); err != nil { +		return nil, err  	} +	return details, nil +} + +func oidcSubjectUUIDFromClaims(claims map[string]any) (subject uuid.UUID, err error) {  	var ( +		ok    bool  		raw   any  		claim string -		err   error  	)  	if raw, ok = claims[oidc.ClaimSubject]; !ok { -		return uuid.UUID{}, false +		return uuid.UUID{}, fmt.Errorf("error retrieving claim 'sub' from the original claims")  	}  	if claim, ok = raw.(string); !ok { -		return uuid.UUID{}, false +		return uuid.UUID{}, fmt.Errorf("error asserting claim 'sub' as a string from the original claims")  	}  	if subject, err = uuid.Parse(claim); err != nil { -		return uuid.UUID{}, false +		return uuid.UUID{}, fmt.Errorf("error parsing claim 'sub' as a UUIDv4 from the original claims: %w", err)  	} -	return subject, true -} - -func oidcCtxDetailResolver(ctx *middlewares.AutheliaCtx) oidcDetailResolver { -	return func(subject uuid.UUID) (detailer oidc.UserDetailer, err error) { -		var ( -			identifier *model.UserOpaqueIdentifier -			details    *authentication.UserDetails -		) - -		if identifier, err = ctx.Providers.StorageProvider.LoadUserOpaqueIdentifier(ctx, subject); err != nil { -			return nil, err -		} - -		if details, err = ctx.Providers.UserProvider.GetDetails(identifier.Username); err != nil { -			return nil, err -		} - -		return details, nil -	} +	return subject, nil  } - -type oidcDetailResolver func(subject uuid.UUID) (detailer oidc.UserDetailer, err error) diff --git a/internal/handlers/oidc_test.go b/internal/handlers/oidc_test.go deleted file mode 100644 index 05b9da4a9..000000000 --- a/internal/handlers/oidc_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package handlers - -import ( -	"fmt" -	"testing" - -	oauthelia2 "authelia.com/provider/oauth2" -	"github.com/google/uuid" -	"github.com/stretchr/testify/assert" -	"github.com/stretchr/testify/require" - -	"github.com/authelia/authelia/v4/internal/authentication" -	"github.com/authelia/authelia/v4/internal/model" -	"github.com/authelia/authelia/v4/internal/oidc" -	"github.com/authelia/authelia/v4/internal/session" -) - -func TestShouldGrantAppropriateClaimsForScopeProfile(t *testing.T) { -	consent := &model.OAuth2ConsentSession{ -		GrantedScopes: []string{oidc.ScopeProfile}, -	} - -	extraClaims := oidcGrantRequests(nil, consent, &oidcUserSessionJohn) - -	assert.Len(t, extraClaims, 2) - -	require.Contains(t, extraClaims, oidc.ClaimPreferredUsername) -	assert.Equal(t, "john", extraClaims[oidc.ClaimPreferredUsername]) - -	require.Contains(t, extraClaims, oidc.ClaimFullName) -	assert.Equal(t, "John Smith", extraClaims[oidc.ClaimFullName]) -} - -func TestShouldGrantAppropriateClaimsForScopeGroups(t *testing.T) { -	consent := &model.OAuth2ConsentSession{ -		GrantedScopes: []string{oidc.ScopeGroups}, -	} - -	extraClaims := oidcGrantRequests(nil, consent, &oidcUserSessionJohn) - -	assert.Len(t, extraClaims, 1) - -	require.Contains(t, extraClaims, oidc.ClaimGroups) -	assert.Len(t, extraClaims[oidc.ClaimGroups], 2) -	assert.Contains(t, extraClaims[oidc.ClaimGroups], "admin") -	assert.Contains(t, extraClaims[oidc.ClaimGroups], "dev") - -	extraClaims = oidcGrantRequests(nil, consent, &oidcUserSessionFred) - -	assert.Len(t, extraClaims, 1) - -	require.Contains(t, extraClaims, oidc.ClaimGroups) -	assert.Len(t, extraClaims[oidc.ClaimGroups], 1) -	assert.Contains(t, extraClaims[oidc.ClaimGroups], "dev") -} - -func TestShouldGrantAppropriateClaimsForScopeEmail(t *testing.T) { -	consent := &model.OAuth2ConsentSession{ -		GrantedScopes: []string{oidc.ScopeEmail}, -	} - -	extraClaims := oidcGrantRequests(nil, consent, &oidcUserSessionJohn) - -	assert.Len(t, extraClaims, 3) - -	require.Contains(t, extraClaims, oidc.ClaimPreferredEmail) -	assert.Equal(t, "j.smith@authelia.com", extraClaims[oidc.ClaimPreferredEmail]) - -	require.Contains(t, extraClaims, oidc.ClaimEmailAlts) -	assert.Len(t, extraClaims[oidc.ClaimEmailAlts], 1) -	assert.Contains(t, extraClaims[oidc.ClaimEmailAlts], "admin@authelia.com") - -	require.Contains(t, extraClaims, oidc.ClaimEmailVerified) -	assert.Equal(t, true, extraClaims[oidc.ClaimEmailVerified]) - -	extraClaims = oidcGrantRequests(nil, consent, &oidcUserSessionFred) - -	assert.Len(t, extraClaims, 2) - -	require.Contains(t, extraClaims, oidc.ClaimPreferredEmail) -	assert.Equal(t, "f.smith@authelia.com", extraClaims[oidc.ClaimPreferredEmail]) - -	require.Contains(t, extraClaims, oidc.ClaimEmailVerified) -	assert.Equal(t, true, extraClaims[oidc.ClaimEmailVerified]) -} - -func TestShouldGrantAppropriateClaimsForScopeOpenIDAndProfile(t *testing.T) { -	consent := &model.OAuth2ConsentSession{ -		GrantedScopes: []string{oidc.ScopeOpenID, oidc.ScopeProfile}, -	} - -	extraClaims := oidcGrantRequests(nil, consent, &oidcUserSessionJohn) - -	assert.Len(t, extraClaims, 2) - -	require.Contains(t, extraClaims, oidc.ClaimPreferredUsername) -	assert.Equal(t, "john", extraClaims[oidc.ClaimPreferredUsername]) - -	require.Contains(t, extraClaims, oidc.ClaimFullName) -	assert.Equal(t, "John Smith", extraClaims[oidc.ClaimFullName]) - -	extraClaims = oidcGrantRequests(nil, consent, &oidcUserSessionFred) - -	assert.Len(t, extraClaims, 2) - -	require.Contains(t, extraClaims, oidc.ClaimPreferredUsername) -	assert.Equal(t, "fred", extraClaims[oidc.ClaimPreferredUsername]) - -	require.Contains(t, extraClaims, oidc.ClaimFullName) -	assert.Equal(t, extraClaims[oidc.ClaimFullName], "Fred Smith") -} - -func TestOIDCApplyUserInfoClaims(t *testing.T) { -	testCases := []struct { -		name               string -		clientID           string -		scopes             oauthelia2.Arguments -		resolver           oidcDetailResolver -		details            *authentication.UserDetails -		original, expected map[string]any -	}{ -		{ -			name:     "ShouldNotMapClaimsWhenSubjectAbsent", -			clientID: "test", -			scopes:   []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail}, -			details: &authentication.UserDetails{ -				Username:    "john", -				DisplayName: "John Smith", -				Groups:      []string{"abc", "123"}, -				Emails:      []string{"john@example.com", "john.smith@example.com"}, -			}, -			original: map[string]any{}, -			expected: map[string]any{oidc.ClaimAudience: []string{"test"}}, -		}, -		{ -			name:     "ShouldNotMapClaimsWhenSubjectNotUUID", -			clientID: "test", -			scopes:   []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail}, -			details: &authentication.UserDetails{ -				Username:    "john", -				DisplayName: "John Smith", -				Groups:      []string{"abc", "123"}, -				Emails:      []string{"john@example.com", "john.smith@example.com"}, -			}, -			original: map[string]any{oidc.ClaimSubject: "abc"}, -			expected: map[string]any{oidc.ClaimAudience: []string{"test"}, oidc.ClaimSubject: "abc"}, -		}, -		{ -			name:     "ShouldNotMapClaimsWhenSubjectNotString", -			clientID: "test", -			scopes:   []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail}, -			details: &authentication.UserDetails{ -				Username:    "john", -				DisplayName: "John Smith", -				Groups:      []string{"abc", "123"}, -				Emails:      []string{"john@example.com", "john.smith@example.com"}, -			}, -			original: map[string]any{oidc.ClaimSubject: 1}, -			expected: map[string]any{oidc.ClaimAudience: []string{"test"}, oidc.ClaimSubject: 1}, -		}, -		{ -			name:     "ShouldNotMapClaimsWhenScopesAbsent", -			clientID: "test", -			scopes:   []string{oidc.ScopeOpenID}, -			details: &authentication.UserDetails{ -				Username:    "john", -				DisplayName: "John Smith", -				Groups:      []string{"abc", "123"}, -				Emails:      []string{"john@example.com", "john.smith@example.com"}, -			}, -			original: map[string]any{oidc.ClaimSubject: "6f05a84f-de27-47e7-8b95-351966532c42"}, -			expected: map[string]any{oidc.ClaimAudience: []string{"test"}, oidc.ClaimSubject: "6f05a84f-de27-47e7-8b95-351966532c42"}, -		}, -		{ -			name:     "ShouldNotMapClaimsWhenResolverError", -			clientID: "test", -			scopes:   []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail}, -			resolver: func(subject uuid.UUID) (detailer oidc.UserDetailer, err error) { -				return nil, fmt.Errorf("an error") -			}, -			original: map[string]any{oidc.ClaimSubject: "6f05a84f-de27-47e7-8b95-351966532c42"}, -			expected: map[string]any{oidc.ClaimAudience: []string{"test"}, oidc.ClaimSubject: "6f05a84f-de27-47e7-8b95-351966532c42"}, -		}, -		{ -			name:     "ShouldMapAllClaims", -			clientID: "test", -			scopes:   []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail}, -			details: &authentication.UserDetails{ -				Username:    "john", -				DisplayName: "John Smith", -				Groups:      []string{"abc", "123"}, -				Emails:      []string{"john@example.com", "john.smith@example.com"}, -			}, -			original: map[string]any{oidc.ClaimSubject: "6f05a84f-de27-47e7-8b95-351966532c42"}, -			expected: map[string]any{ -				oidc.ClaimAudience:          []string{"test"}, -				oidc.ClaimSubject:           "6f05a84f-de27-47e7-8b95-351966532c42", -				oidc.ClaimFullName:          "John Smith", -				oidc.ClaimPreferredUsername: "john", -				oidc.ClaimGroups:            []string{"abc", "123"}, -				oidc.ClaimPreferredEmail:    "john@example.com", -				oidc.ClaimEmailVerified:     true, -				oidc.ClaimEmailAlts:         []string{"john.smith@example.com"}, -			}, -		}, -		{ -			name:     "ShouldMapAllClaimsWithExtras", -			clientID: "test", -			scopes:   []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail}, -			details: &authentication.UserDetails{ -				Username:    "john", -				DisplayName: "John Smith", -				Groups:      []string{"abc", "123"}, -				Emails:      []string{"john@example.com", "john.smith@example.com"}, -			}, -			original: map[string]any{ -				oidc.ClaimSubject:           "6f05a84f-de27-47e7-8b95-351966532c42", -				oidc.ClaimAudience:          []string{"example"}, -				oidc.ClaimAccessTokenHash:   "abc", -				oidc.ClaimPreferredUsername: "not-john", -				oidc.ClaimGroups:            []string{"old", "999"}, -				oidc.ClaimEmailVerified:     false, -				oidc.ClaimPreferredEmail:    "not-john@example.com", -				oidc.ClaimFullName:          "John Smithy", -				oidc.ClaimEmailAlts:         []string{"john.smithy@example.com"}, -			}, -			expected: map[string]any{ -				oidc.ClaimAudience:          []string{"example", "test"}, -				oidc.ClaimSubject:           "6f05a84f-de27-47e7-8b95-351966532c42", -				oidc.ClaimFullName:          "John Smith", -				oidc.ClaimPreferredUsername: "john", -				oidc.ClaimGroups:            []string{"abc", "123"}, -				oidc.ClaimPreferredEmail:    "john@example.com", -				oidc.ClaimEmailVerified:     true, -				oidc.ClaimEmailAlts:         []string{"john.smith@example.com"}, -			}, -		}, -	} - -	for _, tc := range testCases { -		t.Run(tc.name, func(t *testing.T) { -			claims := map[string]any{} - -			resolver := tc.resolver - -			if resolver == nil { -				resolver = oidcTestDetailerFromSubject(tc.details) -			} - -			oidcApplyUserInfoClaims(tc.clientID, tc.scopes, tc.original, claims, resolver) - -			assert.Equal(t, tc.expected, claims) -		}) -	} -} - -func oidcTestDetailerFromSubject(details *authentication.UserDetails) oidcDetailResolver { -	return func(subject uuid.UUID) (detailer oidc.UserDetailer, err error) { -		return details, nil -	} -} - -var ( -	oidcUserSessionJohn = session.UserSession{ -		Username:    "john", -		Groups:      []string{"admin", "dev"}, -		DisplayName: "John Smith", -		Emails:      []string{"j.smith@authelia.com", "admin@authelia.com"}, -	} - -	oidcUserSessionFred = session.UserSession{ -		Username:    "fred", -		Groups:      []string{"dev"}, -		DisplayName: "Fred Smith", -		Emails:      []string{"f.smith@authelia.com"}, -	} -) diff --git a/internal/model/oidc.go b/internal/model/oidc.go index f7f244658..d8aedbe01 100644 --- a/internal/model/oidc.go +++ b/internal/model/oidc.go @@ -155,6 +155,10 @@ type OAuth2ConsentPreConfig struct {  	Scopes   StringSlicePipeDelimited `db:"scopes"`  	Audience StringSlicePipeDelimited `db:"audience"` + +	RequestedClaims sql.NullString           `db:"requested_claims"` +	SignatureClaims sql.NullString           `db:"signature_claims"` +	GrantedClaims   StringSlicePipeDelimited `db:"granted_claims"`  }  // HasExactGrants returns true if the granted audience and scopes of this consent pre-configuration matches exactly with @@ -173,6 +177,11 @@ func (s *OAuth2ConsentPreConfig) HasExactGrantedScopes(scopes []string) (has boo  	return !utils.IsStringSlicesDifferent(s.Scopes, scopes)  } +// HasClaimsSignature returns true if the requested claims signature of this consent matches exactly with another request. +func (s *OAuth2ConsentPreConfig) HasClaimsSignature(signature string) (has bool) { +	return (s.SignatureClaims.Valid || len(signature) == 0) && strings.EqualFold(signature, s.SignatureClaims.String) +} +  // CanConsent returns true if this pre-configuration can still provide consent.  func (s *OAuth2ConsentPreConfig) CanConsent() bool {  	return !s.Revoked && (!s.ExpiresAt.Valid || s.ExpiresAt.Time.After(time.Now())) @@ -197,6 +206,7 @@ type OAuth2ConsentSession struct {  	GrantedScopes     StringSlicePipeDelimited `db:"granted_scopes"`  	RequestedAudience StringSlicePipeDelimited `db:"requested_audience"`  	GrantedAudience   StringSlicePipeDelimited `db:"granted_audience"` +	GrantedClaims     StringSlicePipeDelimited `db:"granted_claims"`  	PreConfiguration sql.NullInt64  } @@ -207,6 +217,12 @@ func (s *OAuth2ConsentSession) Grant() {  	s.GrantedAudience = s.RequestedAudience  } +func (s *OAuth2ConsentSession) GrantWithClaims(claims []string) { +	s.Grant() + +	s.GrantedClaims = claims +} +  // HasExactGrants returns true if the granted audience and scopes of this consent matches exactly with another  // audience and set of scopes.  func (s *OAuth2ConsentSession) HasExactGrants(scopes, audience []string) (has bool) { diff --git a/internal/oidc/claims.go b/internal/oidc/claims.go new file mode 100644 index 000000000..3e3f50198 --- /dev/null +++ b/internal/oidc/claims.go @@ -0,0 +1,801 @@ +package oidc + +import ( +	"bytes" +	"crypto/sha256" +	"encoding/json" +	"fmt" +	"net/url" +	"sort" +	"strings" +	"time" + +	oauthelia2 "authelia.com/provider/oauth2" + +	"github.com/authelia/authelia/v4/internal/configuration/schema" +	"github.com/authelia/authelia/v4/internal/expression" +	"github.com/authelia/authelia/v4/internal/model" +	"github.com/authelia/authelia/v4/internal/utils" +) + +// NewClaimRequests parses the claims request parameter if set from a http.Request form. +func NewClaimRequests(form url.Values) (requests *ClaimsRequests, err error) { +	var raw string + +	if raw = form.Get(FormParameterClaims); len(raw) == 0 { +		return nil, nil +	} + +	requests = &ClaimsRequests{} + +	if err = json.Unmarshal([]byte(raw), requests); err != nil { +		return nil, oauthelia2.ErrInvalidRequest.WithHint("The OAuth 2.0 client included a malformed 'claims' parameter in the authorization request.").WithWrap(err).WithDebugf("Error occurred attempting to parse the 'claims' parameter: %+v.", err) +	} + +	return requests, nil +} + +type OrderedClaimsRequests OrderedClaimsRequestsRaw + +type OrderedClaimsRequestsRaw struct { +	IDToken  OrderedClaimRequests `json:"id_token,omitempty"` +	UserInfo OrderedClaimRequests `json:"userinfo,omitempty"` +} + +func (ocr *OrderedClaimsRequests) MarshalJSON() ([]byte, error) { +	actual := &OrderedClaimsRequestsRaw{} + +	if len(ocr.IDToken) > 0 { +		actual.IDToken = make(OrderedClaimRequests, len(ocr.IDToken)) + +		copy(actual.IDToken, ocr.IDToken) + +		sort.SliceStable(actual.IDToken, func(i, j int) bool { +			return actual.IDToken[i].Claim < actual.IDToken[j].Claim +		}) +	} + +	if len(ocr.UserInfo) > 0 { +		actual.UserInfo = make(OrderedClaimRequests, len(ocr.UserInfo)) + +		copy(actual.UserInfo, ocr.UserInfo) + +		sort.SliceStable(actual.UserInfo, func(i, j int) bool { +			return actual.UserInfo[i].Claim < actual.UserInfo[j].Claim +		}) +	} + +	return json.Marshal(actual) +} + +func (ocr *OrderedClaimsRequests) Signature() (signature string, err error) { +	_, signature, err = ocr.Serialized() + +	return +} + +func (ocr *OrderedClaimsRequests) Serialized() (serialized, signature string, err error) { +	var data []byte + +	if data, err = json.Marshal(ocr); err != nil { +		return "", "", err +	} + +	hash := sha256.New() + +	hash.Write(data) + +	return string(data), fmt.Sprintf("%x", hash.Sum(nil)), nil +} + +type OrderedClaimRequests []OrderedClaimRequest + +func (ocr OrderedClaimRequests) MarshalJSON() ([]byte, error) { +	var buf bytes.Buffer + +	buf.WriteString("{") + +	for i, request := range ocr { +		if i > 0 { +			buf.WriteString(",") +		} + +		key, err := json.Marshal(request.Claim) +		if err != nil { +			return nil, err +		} + +		val, err := json.Marshal(request.Request) +		if err != nil { +			return nil, err +		} + +		buf.Write(key) +		buf.WriteString(":") +		buf.Write(val) +	} + +	buf.WriteString("}") + +	return buf.Bytes(), nil +} + +type OrderedClaimRequest struct { +	Claim   string +	Request *ClaimRequest +} + +// ClaimsRequests is a request for a particular set of claims. +type ClaimsRequests struct { +	IDToken  map[string]*ClaimRequest `json:"id_token,omitempty"` +	UserInfo map[string]*ClaimRequest `json:"userinfo,omitempty"` +} + +func (r *ClaimsRequests) ToOrdered() *OrderedClaimsRequests { +	requests := &OrderedClaimsRequests{} + +	if len(r.IDToken) > 0 { +		requests.IDToken = OrderedClaimRequests{} + +		for claim, request := range r.IDToken { +			requests.IDToken = append(requests.IDToken, OrderedClaimRequest{Claim: claim, Request: request}) +		} +	} + +	if len(r.UserInfo) > 0 { +		requests.UserInfo = OrderedClaimRequests{} + +		for claim, request := range r.UserInfo { +			requests.UserInfo = append(requests.UserInfo, OrderedClaimRequest{Claim: claim, Request: request}) +		} +	} + +	return requests +} + +func (r *ClaimsRequests) Signature() (signature string, err error) { +	return r.ToOrdered().Signature() +} + +func (r *ClaimsRequests) Serialized() (serialized, signature string, err error) { +	return r.ToOrdered().Serialized() +} + +// GetIDTokenRequests returns the IDToken value. +func (r *ClaimsRequests) GetIDTokenRequests() (requests map[string]*ClaimRequest) { +	if r == nil { +		return nil +	} + +	return r.IDToken +} + +// GetUserInfoRequests returns the UserInfo value. +func (r *ClaimsRequests) GetUserInfoRequests() (requests map[string]*ClaimRequest) { +	if r == nil { +		return nil +	} + +	return r.UserInfo +} + +// MatchesSubject returns true if this *ClaimsRequests matches the subject. i.e. if the claims parameter requires a +// specific subject and that value does not match the current value it returns false, otherwise it returns true as well +// as the subject value. +func (r *ClaimsRequests) MatchesSubject(subject string) (requested string, ok bool) { +	if r == nil { +		return "", true +	} + +	return r.stringMatch(subject, ClaimSubject) +} + +func (r *ClaimsRequests) MatchesIssuer(issuer *url.URL) (requested string, ok bool) { +	if r == nil { +		return "", true +	} + +	return r.stringMatch(issuer.String(), ClaimIssuer) +} + +func (r *ClaimsRequests) stringMatch(expected, claim string) (requested string, ok bool) { +	var request *ClaimRequest + +	if r.UserInfo != nil { +		if request, ok = r.UserInfo[claim]; ok { +			if request != nil && request.Value != nil { +				if requested, ok = request.Value.(string); !ok { +					return "", false +				} + +				if request.Value != expected { +					return requested, false +				} +			} +		} +	} + +	if r.IDToken != nil { +		if request, ok = r.IDToken[claim]; ok { +			if request != nil && request.Value != nil { +				if requested, ok = request.Value.(string); !ok { +					return "", false +				} + +				if request.Value != nil && request.Value != expected { +					return requested, false +				} +			} +		} +	} + +	return requested, true +} + +func (r *ClaimsRequests) ToSlice() (claims []string) { +	var essential []string + +	claims, essential = r.ToSlices() + +	for _, claim := range essential { +		if utils.IsStringInSlice(claim, claims) { +			continue +		} + +		claims = append(claims, claim) +	} + +	return claims +} + +// ToSlices returns the claims in two distinct slices where the first is the requested claims i.e. optional, and the +// second is the essential claims. +func (r *ClaimsRequests) ToSlices() (claims, essential []string) { +	if r == nil { +		return nil, nil +	} + +	var ( +		ok      bool +		claim   string +		request *ClaimRequest +	) + +	for claim, request = range r.IDToken { +		if request != nil && request.Essential { +			essential = append(essential, claim) +		} else if request, ok = r.UserInfo[claim]; ok && request != nil && request.Essential { +			essential = append(essential, claim) +		} else { +			claims = append(claims, claim) +		} +	} + +	for claim, request = range r.UserInfo { +		if utils.IsStringInSlice(claim, claims) || utils.IsStringInSlice(claim, essential) { +			continue +		} + +		if request != nil && request.Essential { +			essential = append(essential, claim) +		} else { +			claims = append(claims, claim) +		} +	} + +	return claims, essential +} + +// ClaimRequest is a request for a particular claim. +type ClaimRequest struct { +	Essential bool  `json:"essential,omitempty"` +	Value     any   `json:"value,omitempty"` +	Values    []any `json:"values,omitempty"` +} + +func (r *ClaimRequest) String() (value string) { +	if r == nil { +		return "" +	} + +	var parts []string + +	if r.Value != nil { +		parts = append(parts, fmt.Sprintf("value '%v'", r.Value)) +	} + +	if r.Values != nil { +		items := make([]string, len(r.Values)) + +		for i, item := range r.Values { +			items[i] = fmt.Sprintf("%v", item) +		} + +		parts = append(parts, fmt.Sprintf("values ['%s']", strings.Join(items, "','"))) +	} + +	if len(parts) == 0 { +		return fmt.Sprintf("essential '%t'", r.Essential) +	} + +	return fmt.Sprintf("%s, essential '%t'", strings.Join(parts, ", "), r.Essential) +} + +// Matches is a convenience function which tests if a particular value matches this claims request. +// +//nolint:gocyclo +func (r *ClaimRequest) Matches(value any) (match bool) { +	if r == nil { +		return true +	} + +	if r.Value == nil && r.Values == nil { +		return true +	} + +	if f, ok := float64As(value); ok { +		return float64Match(f, r.Value, r.Values) +	} + +	switch t := value.(type) { +	case bool: +		if r.Value != nil { +			if t == r.Value { +				return true +			} +		} + +		if r.Values != nil { +			found := false + +			for _, v := range r.Values { +				if t == v { +					found = true + +					break +				} +			} + +			if found { +				return true +			} +		} +	case string: +		if r.Value != nil { +			if t == r.Value { +				return true +			} +		} + +		if r.Values != nil { +			found := false + +			for _, v := range r.Values { +				if t == v { +					found = true + +					break +				} +			} + +			if found { +				return true +			} +		} +	} + +	return false +} + +type ClaimResolver func(attribute string) (value any, ok bool) + +type ClaimsStrategy interface { +	ValidateClaimsRequests(ctx Context, strategy oauthelia2.ScopeStrategy, client Client, requests *ClaimsRequests) (err error) +	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) +} + +func NewDefaultCustomClaimsStrategy() (strategy *CustomClaimsStrategy) { +	return &CustomClaimsStrategy{ +		claimsIDToken:     []string{}, +		claimsAccessToken: []string{}, +		scopes: map[string]map[string]string{ +			ScopeProfile: { +				ClaimFullName:          expression.AttributeUserDisplayName, +				ClaimGivenName:         expression.AttributeUserGivenName, +				ClaimFamilyName:        expression.AttributeUserFamilyName, +				ClaimMiddleName:        expression.AttributeUserMiddleName, +				ClaimNickname:          expression.AttributeUserNickname, +				ClaimPreferredUsername: expression.AttributeUserUsername, +				ClaimProfile:           expression.AttributeUserProfile, +				ClaimPicture:           expression.AttributeUserPicture, +				ClaimWebsite:           expression.AttributeUserWebsite, +				ClaimGender:            expression.AttributeUserGender, +				ClaimBirthdate:         expression.AttributeUserBirthdate, +				ClaimZoneinfo:          expression.AttributeUserZoneInfo, +				ClaimLocale:            expression.AttributeUserLocale, +				ClaimUpdatedAt:         expression.AttributeUserUpdatedAt, +			}, +			ScopeEmail: { +				ClaimEmail:         expression.AttributeUserEmail, +				ClaimEmailAlts:     expression.AttributeUserEmailsExtra, +				ClaimEmailVerified: expression.AttributeUserEmailVerified, +			}, +			ScopePhone: { +				ClaimPhoneNumber:         expression.AttributeUserPhoneNumberRFC3966, +				ClaimPhoneNumberVerified: expression.AttributeUserPhoneNumberVerified, +			}, +			ScopeAddress: { +				ClaimAddress: expression.AttributeUserAddress, +			}, +			ScopeGroups: { +				ClaimGroups: expression.AttributeUserGroups, +			}, +		}, +	} +} + +func NewCustomClaimsStrategy(client schema.IdentityProvidersOpenIDConnectClient, scopes map[string]schema.IdentityProvidersOpenIDConnectScope, policies map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy) (strategy *CustomClaimsStrategy) { +	strategy = NewDefaultCustomClaimsStrategy() + +	if client.ClaimsPolicy == "" { +		return strategy +	} + +	var ( +		policy  schema.IdentityProvidersOpenIDConnectClaimsPolicy +		mapping schema.IdentityProvidersOpenIDConnectScope +		claim   schema.IdentityProvidersOpenIDConnectCustomClaim + +		ok   bool +		name string +	) + +	if policy, ok = policies[client.ClaimsPolicy]; !ok { +		return strategy +	} + +	if policy.IDToken != nil { +		strategy.claimsIDToken = policy.IDToken +	} + +	if policy.AccessToken != nil { +		strategy.claimsAccessToken = policy.AccessToken +	} + +	for _, scope := range client.Scopes { +		if mapping, ok = scopes[scope]; !ok { +			continue +		} + +		if _, ok = strategy.scopes[scope]; !ok { +			strategy.scopes[scope] = make(map[string]string) +		} + +		for _, name = range mapping.Claims { +			switch name { +			case ClaimFullName: +				strategy.scopes[scope][name] = expression.AttributeUserDisplayName +			case ClaimPreferredUsername: +				strategy.scopes[scope][name] = expression.AttributeUserUsername +			case ClaimEmailAlts: +				strategy.scopes[scope][name] = expression.AttributeUserEmailsExtra +			case ClaimPhoneNumber: +				strategy.scopes[scope][name] = expression.AttributeUserPhoneNumberRFC3966 +			default: +				claim = policy.CustomClaims[name] + +				if claim.Attribute == "" { +					strategy.scopes[scope][name] = name +				} else { +					strategy.scopes[scope][name] = claim.Attribute +				} +			} +		} +	} + +	return strategy +} + +type CustomClaimsStrategy struct { +	claimsIDToken     []string +	claimsAccessToken []string +	scopes            map[string]map[string]string +} + +//nolint:gocyclo +func (s *CustomClaimsStrategy) ValidateClaimsRequests(ctx Context, strategy oauthelia2.ScopeStrategy, client Client, requests *ClaimsRequests) (err error) { +	if requests == nil { +		return nil +	} + +	scopes := client.GetScopes() + +	claimMatches := map[string][]string{} + +	if requests.IDToken != nil { +		for claim := range requests.IDToken { +			for scope, claims := range s.scopes { +				if _, ok := claims[claim]; !ok { +					continue +				} + +				if scp, ok := claimMatches[claim]; ok { +					claimMatches[claim] = append(scp, scope) +				} else { +					claimMatches[claim] = []string{scope} +				} +			} +		} +	} + +	if requests.UserInfo != nil { +		for claim := range requests.UserInfo { +			for scope, claims := range s.scopes { +				if _, ok := claims[claim]; !ok { +					continue +				} + +				if scp, ok := claimMatches[claim]; ok { +					claimMatches[claim] = append(scp, scope) +				} else { +					claimMatches[claim] = []string{scope} +				} +			} +		} +	} + +	invalid := map[string][]string{} + +claims: +	for claim, possibleScopes := range claimMatches { +		var requiredScopes []string + +		for _, scope := range possibleScopes { +			if strategy(scopes, scope) { +				continue claims +			} + +			requiredScopes = append(requiredScopes, scope) +		} + +		for _, scope := range requiredScopes { +			if invalidClaims, ok := invalid[scope]; ok { +				invalid[scope] = append(invalidClaims, claim) +			} else { +				invalid[scope] = []string{claim} +			} +		} +	} + +	if len(invalid) == 0 { +		return nil +	} + +	elements := make([]string, 0, len(invalid)) + +	for scope, claims := range invalid { +		elements = append(elements, fmt.Sprintf("claims %s require the '%s' scope", utils.StringJoinAnd(claims), scope)) +	} + +	return oauthelia2.ErrInvalidRequest.WithDebugf("The authorization request contained a claims request which is not permitted to make. The %s; but these scopes are absent from the client registration.", strings.Join(elements, ", ")) +} + +func (s *CustomClaimsStrategy) 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) { +	resolver := ctx.GetProviderUserAttributeResolver() + +	if resolver == nil { +		return oauthelia2.ErrServerError.WithDebug("The claims strategy had an error populating the ID Token Claims. Error occurred obtaining the attribute resolver.") +	} + +	resolve := func(claim string) (value any, ok bool) { +		return resolver.Resolve(claim, detailer, updated) +	} + +	s.populateClaimsOriginal(original, extra) +	s.populateClaimsAudience(client, original, extra) +	s.populateClaimsScoped(ctx, strategy, client, scopes, resolve, s.claimsIDToken, extra) +	s.populateClaimsRequested(ctx, strategy, client, requests, claims, resolve, extra) + +	return nil +} + +func (s *CustomClaimsStrategy) 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) { +	resolver := ctx.GetProviderUserAttributeResolver() + +	if resolver == nil { +		return oauthelia2.ErrServerError.WithDebug("The claims strategy had an error populating the ID Token Claims. Error occurred obtaining the attribute resolver.") +	} + +	resolve := func(attribute string) (value any, ok bool) { +		return resolver.Resolve(attribute, detailer, updated) +	} + +	s.populateClaimsOriginalUserInfo(original, extra) +	s.populateClaimsScoped(ctx, strategy, client, scopes, resolve, nil, extra) +	s.populateClaimsRequested(ctx, strategy, client, requests, claims, resolve, extra) + +	return nil +} + +func (s *CustomClaimsStrategy) PopulateClientCredentialsUserInfoClaims(ctx Context, client Client, original, extra map[string]any) (err error) { +	s.populateClaimsOriginal(original, extra) +	s.populateClaimsAudience(client, original, extra) + +	return nil +} + +func (s *CustomClaimsStrategy) isClaimAllowed(claim string, allowed []string) (isAllowed bool) { +	if allowed == nil { +		return true +	} + +	return utils.IsStringInSlice(claim, allowed) +} + +func (s *CustomClaimsStrategy) populateClaimsOriginalUserInfo(original, extra map[string]any) { +	for claim, value := range original { +		switch claim { +		case ClaimSubject: +			extra[claim] = value +		default: +			continue +		} +	} +} + +func (s *CustomClaimsStrategy) populateClaimsOriginal(original, extra map[string]any) { +	for claim, value := range original { +		switch claim { +		case ClaimJWTID, ClaimSessionID, ClaimAccessTokenHash, ClaimCodeHash, ClaimExpirationTime, ClaimNonce, ClaimStateHash: +			// Skip special OpenID Connect 1.0 Claims. +			continue +		case ClaimFullName, ClaimGivenName, ClaimFamilyName, ClaimMiddleName, ClaimNickname, ClaimPreferredUsername, ClaimProfile, ClaimPicture, ClaimWebsite, ClaimEmail, ClaimEmailVerified, ClaimGender, ClaimBirthdate, ClaimZoneinfo, ClaimLocale, ClaimPhoneNumber, ClaimPhoneNumberVerified, ClaimAddress: +			// Skip the standard claims. +			continue +		default: +			extra[claim] = value +		} +	} +} + +func (s *CustomClaimsStrategy) populateClaimsAudience(client Client, original, extra map[string]any) { +	if clientID := client.GetID(); clientID != "" { +		audience, ok := GetAudienceFromClaims(original) + +		if !ok || len(audience) == 0 { +			audience = []string{clientID} +		} else if !utils.IsStringInSlice(clientID, audience) { +			audience = append(audience, clientID) +		} + +		extra[ClaimAudience] = audience +	} +} + +func (s *CustomClaimsStrategy) populateClaimsScoped(_ Context, strategy oauthelia2.ScopeStrategy, client Client, scopes oauthelia2.Arguments, resolve ClaimResolver, allowed []string, extra map[string]any) { +	if resolve == nil { +		return +	} + +	for scope, claims := range s.scopes { +		if !strategy(scopes, scope) { +			continue +		} + +		for claim, attribute := range claims { +			s.populateClaim(client, claim, attribute, allowed, resolve, extra, nil) +		} +	} +} + +func (s *CustomClaimsStrategy) populateClaimsRequested(_ Context, strategy oauthelia2.ScopeStrategy, client Client, requests map[string]*ClaimRequest, claims oauthelia2.Arguments, resolve ClaimResolver, extra map[string]any) { +	if requests == nil || resolve == nil { +		return +	} + +	scopes := client.GetScopes() + +claim: +	for claim, request := range requests { +		for scope, claimSet := range s.scopes { +			if !strategy(scopes, scope) { +				continue +			} + +			if (request == nil || !request.Essential) && !claims.Has(claim) { +				continue +			} + +			attribute, ok := claimSet[claim] + +			if !ok { +				continue +			} + +			s.populateClaim(client, claim, attribute, nil, resolve, extra, request) + +			continue claim +		} +	} +} + +func (s *CustomClaimsStrategy) populateClaim(_ Client, claim, attribute string, allowed []string, resolve ClaimResolver, extra map[string]any, request *ClaimRequest) { +	if !s.isClaimAllowed(claim, allowed) { +		return +	} + +	value, ok := resolve(attribute) + +	if !ok || value == nil { +		return +	} + +	var str string + +	if str, ok = value.(string); ok { +		if str == "" { +			return +		} +	} + +	if request != nil { +		if !request.Matches(value) { +			return +		} +	} + +	extra[claim] = value +} + +// GrantScopeAudienceConsent grants all scopes and audience values that have received consent. +func GrantScopeAudienceConsent(ar oauthelia2.Requester, consent *model.OAuth2ConsentSession) { +	if ar == nil || consent == nil { +		return +	} + +	for _, scope := range consent.GrantedScopes { +		ar.GrantScope(scope) +	} + +	for _, audience := range consent.GrantedAudience { +		ar.GrantAudience(audience) +	} +} + +// GetAudienceFromClaims retrieves the various formats of the 'aud' claim and returns them as a []string. +func GetAudienceFromClaims(claims map[string]any) (audience []string, ok bool) { +	var aud any + +	if aud, ok = claims[ClaimAudience]; ok { +		switch v := aud.(type) { +		case string: +			if v == "" { +				break +			} + +			ok = true + +			audience = []string{v} +		case []any: +			var value string + +			for _, a := range v { +				if value, ok = a.(string); !ok { +					return nil, false +				} + +				audience = append(audience, value) +			} + +			ok = true +		case []string: +			ok = true + +			audience = v +		} +	} + +	return audience, ok +} diff --git a/internal/oidc/claims_test.go b/internal/oidc/claims_test.go new file mode 100644 index 000000000..a476d38d4 --- /dev/null +++ b/internal/oidc/claims_test.go @@ -0,0 +1,1686 @@ +package oidc_test + +import ( +	"encoding/json" +	"fmt" +	"net/url" +	"reflect" +	"testing" +	"time" + +	oauthelia2 "authelia.com/provider/oauth2" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/require" +	"golang.org/x/text/cases" +	"golang.org/x/text/language" + +	"github.com/authelia/authelia/v4/internal/configuration/schema" +	"github.com/authelia/authelia/v4/internal/model" +	"github.com/authelia/authelia/v4/internal/oidc" +	"github.com/authelia/authelia/v4/internal/utils" +) + +func TestClaimsMarshall(t *testing.T) { +	r := &oidc.ClaimsRequests{ +		IDToken: map[string]*oidc.ClaimRequest{ +			"example": nil, +			"another": nil, +		}, +		UserInfo: map[string]*oidc.ClaimRequest{ +			"example": nil, +			"another": nil, +		}, +	} + +	o := r.ToOrdered() + +	data, err := json.Marshal(o) +	require.NoError(t, err) + +	assert.Equal(t, `{"id_token":{"another":null,"example":null},"userinfo":{"another":null,"example":null}}`, string(data)) +} + +func TestClaimValidate(t *testing.T) { +	config := schema.IdentityProvidersOpenIDConnect{ +		Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +			"example": { +				Claims: []string{oidc.ClaimPreferredUsername, oidc.ClaimFullName, oidc.ClaimEmailAlts, oidc.ClaimPhoneNumber}, +			}, +		}, +		Clients: []schema.IdentityProvidersOpenIDConnectClient{ +			{ +				ID:           "example-client", +				Scopes:       []string{oidc.ScopeOpenID, "example"}, +				ClaimsPolicy: "default", +			}, +		}, +		ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +			"default": {}, +		}, +	} + +	strategy := oidc.NewCustomClaimsStrategy(config.Clients[0], config.Scopes, config.ClaimsPolicies) + +	client := &oidc.RegisteredClient{ +		ID:     config.Clients[0].ID, +		Scopes: config.Clients[0].Scopes, +	} + +	requests := &oidc.ClaimsRequests{ +		IDToken: map[string]*oidc.ClaimRequest{ +			oidc.ClaimFullName: nil, +			oidc.ClaimPreferredUsername: { +				Essential: true, +			}, +		}, +	} + +	ctx := &TestContext{} + +	detailer := &testDetailer{ +		username: "john", +		groups:   []string{"abc", "123"}, +		name:     "John Smith", +		emails:   []string{"john.smith@authelia.com"}, +		extra:    map[string]any{}, +	} + +	extra := map[string]any{} + +	err := strategy.PopulateIDTokenClaims(ctx, oauthelia2.ExactScopeStrategy, client, nil, []string{oidc.ClaimPreferredUsername, oidc.ClaimFullName}, requests.IDToken, detailer, time.Now(), nil, extra) +	assert.NoError(t, oauthelia2.ErrorToDebugRFC6749Error(err)) + +	assert.Equal(t, map[string]any{oidc.ClaimAudience: []string{config.Clients[0].ID}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john"}, extra) + +	err = strategy.ValidateClaimsRequests(ctx, oauthelia2.ExactScopeStrategy, client, requests) + +	assert.NoError(t, oauthelia2.ErrorToDebugRFC6749Error(err)) +} + +func TestClaimRequest_String(t *testing.T) { +	testCases := []struct { +		name     string +		have     *oidc.ClaimRequest +		expected string +	}{ +		{ +			"ShouldHandleBlank", +			&oidc.ClaimRequest{}, +			"essential 'false'", +		}, +		{ +			"ShouldHandleNil", +			nil, +			"", +		}, +		{ +			"ShouldHandleValue", +			&oidc.ClaimRequest{Value: "example"}, +			"value 'example', essential 'false'", +		}, +		{ +			"ShouldHandleValue", +			&oidc.ClaimRequest{Essential: true}, +			"essential 'true'", +		}, +		{ +			"ShouldHandleValues", +			&oidc.ClaimRequest{Essential: true, Values: []any{"abc", "123"}}, +			"values ['abc','123'], essential 'true'", +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			assert.Equal(t, tc.expected, tc.have.String()) +		}) +	} +} + +func TestClaimRequest_Matches(t *testing.T) { +	testCases := []struct { +		name     string +		have     *oidc.ClaimRequest +		value    any +		expected bool +	}{ +		{ +			"ShouldMatchUndefined", +			&oidc.ClaimRequest{}, +			"abc", +			true, +		}, +		{ +			"ShouldMatchNil", +			nil, +			"abc", +			true, +		}, +		{ +			"ShouldMatchInt64ToInt", +			&oidc.ClaimRequest{Value: 123}, +			int64(123), +			true, +		}, +		{ +			"ShouldMatchInt64ToIntArray", +			&oidc.ClaimRequest{Values: []any{123}}, +			int64(123), +			true, +		}, +		{ +			"ShouldMatchInt64ToInt", +			&oidc.ClaimRequest{Value: 123}, +			int64(123), +			true, +		}, +		{ +			"ShouldMatchStringToString", +			&oidc.ClaimRequest{Value: "abc"}, +			"abc", +			true, +		}, +		{ +			"ShouldMatchStringToStringArray", +			&oidc.ClaimRequest{Values: []any{"abc"}}, +			"abc", +			true, +		}, +		{ +			"ShouldMatchBoolToBool", +			&oidc.ClaimRequest{Value: true}, +			true, +			true, +		}, +		{ +			"ShouldNotMatchBoolToBool", +			&oidc.ClaimRequest{Value: true}, +			false, +			false, +		}, +		{ +			"ShouldMatchBoolToBoolArray", +			&oidc.ClaimRequest{Values: []any{true}}, +			true, +			true, +		}, +		{ +			"ShouldNotMatchBoolToBoolArray", +			&oidc.ClaimRequest{Values: []any{false}}, +			true, +			false, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			assert.Equal(t, tc.expected, tc.have.Matches(tc.value)) +		}) +	} +} + +func TestNewClaimRequestsMatcher(t *testing.T) { +	attribute := "example-attribute" +	policy := "example-policy" +	scope := "example-scope" +	scopes := []string{"example-scope"} +	claim := "example-claim" + +	config := schema.IdentityProvidersOpenIDConnect{ +		Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +			scope: { +				Claims: []string{claim}, +			}, +		}, +		ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +			policy: { +				CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +					claim: { +						Attribute: attribute, +					}, +				}, +			}, +		}, +		Clients: []schema.IdentityProvidersOpenIDConnectClient{ +			{ +				ID:           "example-client", +				Scopes:       scopes, +				ClaimsPolicy: policy, +			}, +		}, +	} + +	client := &oidc.RegisteredClient{ +		ID:             config.Clients[0].ID, +		ClaimsStrategy: oidc.NewCustomClaimsStrategy(config.Clients[0], config.Scopes, config.ClaimsPolicies), +		Scopes:         config.Clients[0].Scopes, +	} + +	strategy := oauthelia2.ExactScopeStrategy +	start := time.Unix(123123123, 0) +	ctx := &TestContext{} + +	var ( +		extra map[string]any +	) + +	srcNumbers := []any{ +		float64(123), +		float32(123), +		int64(123), +		int32(123), +		int16(123), +		int8(123), +		123, +		uint64(123), +		uint32(123), +		uint16(123), +		uint8(123), +		uint(123), +	} + +	testCases := []struct { +		name       string +		claims     []string +		requested  any +		detailer   any +		expectedID any +		expectedUI any +		numbers    bool +		err        string +	}{ +		{ +			"ShouldPassString", +			[]string{"example-claim"}, +			"apple", +			"apple", +			"apple", +			"apple", +			false, +			"", +		}, +		{ +			"ShouldNotPassNonEssentialString", +			[]string{}, +			"apple", +			"apple", +			nil, +			"apple", +			false, +			"", +		}, +		{ +			"ShouldNotPassMismatchedTypes", +			[]string{}, +			"apple", +			123, +			nil, +			123, +			false, +			"", +		}, +		{ +			name:    "ShouldPassNumbers", +			claims:  []string{"example-claim"}, +			numbers: true, +		}, +	} + +	caser := cases.Title(language.English) + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			if tc.numbers { +				for _, src := range srcNumbers { +					for _, dst := range srcNumbers { +						st := reflect.TypeOf(src) +						dt := reflect.TypeOf(dst) + +						t.Run(fmt.Sprintf("%sTo%s", caser.String(st.String()), caser.String(dt.String())), func(t *testing.T) { +							extra = make(map[string]any) + +							detailer := &testDetailer{ +								username: "john", +								groups:   []string{"abc", "123"}, +								name:     "John Smith", +								emails:   []string{"john.smith@authelia.com"}, +								extra: map[string]any{ +									attribute: dst, +								}, +							} + +							requests := &oidc.ClaimsRequests{ +								IDToken: map[string]*oidc.ClaimRequest{ +									claim: { +										Value: src, +									}, +								}, +								UserInfo: map[string]*oidc.ClaimRequest{ +									claim: { +										Value: src, +									}, +								}, +							} + +							assert.NoError(t, oauthelia2.ErrorToDebugRFC6749Error(client.ClaimsStrategy.PopulateIDTokenClaims(ctx, strategy, client, scopes, tc.claims, requests.IDToken, detailer, start, map[string]any{}, extra))) + +							assert.Equal(t, dst, extra[claim]) + +							extra = make(map[string]any) + +							assert.NoError(t, client.ClaimsStrategy.PopulateUserInfoClaims(ctx, strategy, client, scopes, tc.claims, requests.UserInfo, detailer, start, map[string]any{}, extra)) + +							assert.Equal(t, dst, extra[claim]) + +							requests = &oidc.ClaimsRequests{ +								IDToken: map[string]*oidc.ClaimRequest{ +									claim: { +										Values: []any{src}, +									}, +								}, +								UserInfo: map[string]*oidc.ClaimRequest{ +									claim: { +										Values: []any{src}, +									}, +								}, +							} + +							assert.NoError(t, client.ClaimsStrategy.PopulateIDTokenClaims(ctx, strategy, client, scopes, tc.claims, requests.IDToken, detailer, start, map[string]any{}, extra)) + +							assert.Equal(t, dst, extra[claim]) + +							extra = make(map[string]any) + +							assert.NoError(t, client.ClaimsStrategy.PopulateUserInfoClaims(ctx, strategy, client, scopes, tc.claims, requests.UserInfo, detailer, start, map[string]any{}, extra)) + +							assert.Equal(t, dst, extra[claim]) +						}) +					} +				} + +				return +			} + +			extra = make(map[string]any) + +			detailer := &testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					attribute: tc.detailer, +				}, +			} + +			requests := &oidc.ClaimsRequests{ +				IDToken: map[string]*oidc.ClaimRequest{ +					claim: { +						Value: tc.requested, +					}, +				}, +				UserInfo: map[string]*oidc.ClaimRequest{ +					claim: { +						Value: tc.requested, +					}, +				}, +			} + +			err := client.ClaimsStrategy.PopulateIDTokenClaims(ctx, strategy, client, scopes, tc.claims, requests.IDToken, detailer, start, map[string]any{}, extra) + +			if tc.err == "" { +				require.NoError(t, oauthelia2.ErrorToDebugRFC6749Error(err)) +				assert.Equal(t, tc.expectedID, extra[claim]) +			} else { +				assert.EqualError(t, oauthelia2.ErrorToDebugRFC6749Error(err), tc.err) +			} + +			extra = make(map[string]any) + +			err = client.ClaimsStrategy.PopulateUserInfoClaims(ctx, strategy, client, scopes, tc.claims, requests.UserInfo, detailer, start, map[string]any{}, extra) + +			if tc.err == "" { +				require.NoError(t, err) +				assert.Equal(t, tc.expectedUI, extra[claim]) +			} else { +				assert.EqualError(t, oauthelia2.ErrorToDebugRFC6749Error(err), tc.err) +			} +		}) +	} +} + +func TestNewClaimRequests(t *testing.T) { +	testCases := []struct { +		name              string +		have              any +		err               string +		expected          bool +		subject           string +		issuer            *url.URL +		badSubjects       []string +		badIssuers        []*url.URL +		claims, essential []string +		idToken, userinfo map[string]*oidc.ClaimRequest +	}{ +		{ +			"ShouldParse", +			`{"id_token":{"sub":{"value":"aaaa"}}}`, +			"", +			true, +			"aaaa", +			&url.URL{}, +			[]string{"nah", "aaa"}, +			[]*url.URL{}, +			[]string{"sub"}, +			nil, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value: "aaaa", +				}, +			}, +			nil, +		}, +		{ +			"ShouldParseUserInfo", +			`{"userinfo":{"sub":{"value":"aaaa"}}}`, +			"", +			true, +			"aaaa", +			&url.URL{}, +			[]string{"nah", "aaa"}, +			[]*url.URL{}, +			[]string{"sub"}, +			nil, +			nil, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value: "aaaa", +				}, +			}, +		}, +		{ +			"ShouldParseBoth", +			`{"userinfo":{"sub":{"value":"aaaa"}},"id_token":{"sub":{"value":"aaaa"}}}`, +			"", +			true, +			"aaaa", +			&url.URL{}, +			[]string{"nah", "aaa"}, +			[]*url.URL{}, +			[]string{"sub"}, +			nil, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value: "aaaa", +				}, +			}, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value: "aaaa", +				}, +			}, +		}, +		{ +			"ShouldParseBothIDTokenEssential", +			`{"userinfo":{"sub":{"value":"aaaa"}},"id_token":{"sub":{"value":"aaaa","essential":true}}}`, +			"", +			true, +			"aaaa", +			&url.URL{}, +			[]string{"nah", "aaa"}, +			[]*url.URL{}, +			nil, +			[]string{"sub"}, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value:     "aaaa", +					Essential: true, +				}, +			}, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value: "aaaa", +				}, +			}, +		}, +		{ +			"ShouldParseBothUserInfoEssential", +			`{"userinfo":{"sub":{"value":"aaaa","essential":true}},"id_token":{"sub":{"value":"aaaa"}}}`, +			"", +			true, +			"aaaa", +			&url.URL{}, +			[]string{"nah", "aaa"}, +			[]*url.URL{}, +			nil, +			[]string{"sub"}, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value: "aaaa", +				}, +			}, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value:     "aaaa", +					Essential: true, +				}, +			}, +		}, +		{ +			"ShouldParseUserInfoEssential", +			`{"userinfo":{"sub":{"value":"aaaa","essential":true}}}`, +			"", +			true, +			"aaaa", +			&url.URL{}, +			[]string{"nah", "aaa"}, +			[]*url.URL{}, +			nil, +			[]string{"sub"}, +			nil, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value:     "aaaa", +					Essential: true, +				}, +			}, +		}, +		{ +			"ShouldParseBothEssential", +			`{"userinfo":{"sub":{"value":"aaaa","essential":true}},"id_token":{"sub":{"value":"aaaa","essential":true}}}`, +			"", +			true, +			"aaaa", +			&url.URL{}, +			[]string{"nah", "aaa"}, +			[]*url.URL{}, +			nil, +			[]string{"sub"}, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value:     "aaaa", +					Essential: true, +				}, +			}, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value:     "aaaa", +					Essential: true, +				}, +			}, +		}, +		{ +			"ShouldNotMatchInteger", +			`{"userinfo":{"sub":{"value":1}}}`, +			"", +			false, +			"", +			&url.URL{}, +			[]string{}, +			[]*url.URL{}, +			[]string{"sub"}, +			nil, +			nil, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value: float64(1), +				}, +			}, +		}, +		{ +			"ShouldNotMatchIntegerIDToken", +			`{"id_token":{"sub":{"value":1}}}`, +			"", +			false, +			"", +			&url.URL{}, +			[]string{}, +			[]*url.URL{}, +			[]string{"sub"}, +			nil, +			map[string]*oidc.ClaimRequest{ +				"sub": { +					Value: float64(1), +				}, +			}, +			nil, +		}, +		{ +			"ShouldParseRequestOnly", +			`{"userinfo":{"sub":null}}`, +			"", +			true, +			"", +			&url.URL{}, +			[]string{}, +			[]*url.URL{}, +			[]string{"sub"}, +			nil, +			nil, +			map[string]*oidc.ClaimRequest{ +				"sub": nil, +			}, +		}, +		{ +			"ShouldNotParse", +			`{"iaaa"}}}`, +			"The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. The OAuth 2.0 client included a malformed 'claims' parameter in the authorization request. Error occurred attempting to parse the 'claims' parameter: invalid character '}' after object key.", +			false, +			"", +			nil, +			nil, +			nil, +			nil, +			nil, +			nil, +			nil, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			form := url.Values{} + +			if tc.have != nil { +				form.Set(oidc.FormParameterClaims, tc.have.(string)) +			} + +			var ( +				requested         string +				ok                bool +				requests          *oidc.ClaimsRequests +				err               error +				claims, essential []string +			) + +			claims, essential = requests.ToSlices() + +			assert.Nil(t, claims) +			assert.Nil(t, essential) + +			assert.Nil(t, requests.ToSlice()) + +			assert.Nil(t, requests.GetIDTokenRequests()) +			assert.Nil(t, requests.GetUserInfoRequests()) + +			requested, ok = requests.MatchesIssuer(nil) +			assert.Equal(t, "", requested) +			assert.True(t, ok) + +			requested, ok = requests.MatchesSubject("xxxx") +			assert.Equal(t, "", requested) +			assert.True(t, ok) + +			requests, err = oidc.NewClaimRequests(form) + +			if tc.err != "" { +				assert.Nil(t, requests) +				assert.EqualError(t, oauthelia2.ErrorToDebugRFC6749Error(err), tc.err) + +				return +			} + +			require.NotNil(t, requests) + +			claims, essential = requests.ToSlices() + +			assert.Equal(t, tc.claims, claims) +			assert.Equal(t, tc.essential, essential) + +			allClaims := requests.ToSlice() + +			assert.True(t, utils.IsStringSliceContainsAll(claims, allClaims)) +			assert.True(t, utils.IsStringSliceContainsAll(essential, allClaims)) + +			requested, ok = requests.MatchesSubject(tc.subject) +			assert.Equal(t, tc.subject, requested) +			assert.Equal(t, tc.expected, ok) + +			requested, ok = requests.MatchesIssuer(tc.issuer) +			assert.Equal(t, tc.issuer.String(), requested) +			assert.True(t, ok) + +			for _, badSubject := range tc.badSubjects { +				requested, ok = requests.MatchesSubject(badSubject) + +				assert.Equal(t, tc.subject, requested) +				assert.False(t, ok) +			} + +			for _, badIssuer := range tc.badIssuers { +				requested, ok = requests.MatchesIssuer(badIssuer) + +				assert.Equal(t, tc.issuer.String(), requested) +				assert.False(t, ok) +			} + +			assert.Equal(t, tc.idToken, requests.GetIDTokenRequests()) +			assert.Equal(t, tc.userinfo, requests.GetUserInfoRequests()) +		}) +	} + +	form := url.Values{} + +	form.Set(oidc.FormParameterClaims, `{"id_token":{"sub":{"value":"aaaa"}}}`) + +	requests, err := oidc.NewClaimRequests(form) +	require.NoError(t, err) + +	assert.NotNil(t, requests) + +	var ( +		requested string +		ok        bool +	) + +	requested, ok = requests.MatchesSubject("aaaa") +	assert.Equal(t, "aaaa", requested) +	assert.True(t, ok) + +	requested, ok = requests.MatchesSubject("aaaaa") +	assert.Equal(t, "aaaa", requested) +	assert.False(t, ok) +} + +func TestNewCustomClaimsStrategy(t *testing.T) { +	testCases := []struct { +		name             string +		config           schema.IdentityProvidersOpenIDConnect +		scopes           oauthelia2.Arguments +		requests         *oidc.ClaimsRequests +		detailer         oidc.UserDetailer +		err              string +		claims           []string +		original         map[string]any +		expectedIDToken  map[string]any +		expectedUserInfo map[string]any +		errIDToken       string +		errUserInfo      string +	}{ +		{ +			"ShouldGrantRequestedClaimsCustom", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			nil, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{}, +			map[string]any{oidc.ClaimAuthenticationTime: 123}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": 123, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldGrantRequestedClaimsIDToken", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +					"alt-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope", "alt-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  map[string]*oidc.ClaimRequest{"example-claim": nil}, +				UserInfo: nil, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{}, +			map[string]any{oidc.ClaimAuthenticationTime: 123}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": 123, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldGrantRequestedClaimsIDTokenRequestedValue", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +					"alt-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope", "alt-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  map[string]*oidc.ClaimRequest{"example-claim": {Value: float64(123)}}, +				UserInfo: nil, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{"example-claim"}, +			map[string]any{oidc.ClaimAuthenticationTime: 123}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}, "example-claim": 123}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": 123, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldGrantRequestedClaimsIDTokenRequestedValueFloat64", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +					"alt-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope", "alt-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  map[string]*oidc.ClaimRequest{"example-claim": {Value: float64(1234)}}, +				UserInfo: nil, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": float64(1234), +				}, +			}, +			"", +			[]string{"example-claim"}, +			map[string]any{oidc.ClaimAuthenticationTime: 123}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}, "example-claim": float64(1234)}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": float64(1234), oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldGrantRequestedClaimsIDTokenRequestedValue", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +					"alt-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope", "alt-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  map[string]*oidc.ClaimRequest{"example-claim": {Value: float64(1234)}}, +				UserInfo: nil, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": int64(1234), +				}, +			}, +			"", +			[]string{"example-claim"}, +			map[string]any{oidc.ClaimAuthenticationTime: 123}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}, "example-claim": int64(1234)}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": int64(1234), oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldGrantNoPolicy", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +					"alt-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy2": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope", "alt-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  map[string]*oidc.ClaimRequest{"example-claim": nil}, +				UserInfo: nil, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{}, +			map[string]any{oidc.ClaimAuthenticationTime: 123}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldGrantRequestedClaimsIDTokenButNotPropagateIDTokenClaims", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  map[string]*oidc.ClaimRequest{"example-claim": nil}, +				UserInfo: nil, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{"example-claim"}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimJWTID: "EXAMPLE", oidc.ClaimFullName: "Not John Smith"}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}, "example-claim": 123}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": 123, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldNotGrantRequestedClaimsIDTokenButNotPropagateIDTokenClaimsNotGranted", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  map[string]*oidc.ClaimRequest{"example-claim": nil}, +				UserInfo: nil, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimJWTID: "EXAMPLE", oidc.ClaimFullName: "Not John Smith"}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": 123, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldGrantRequestedClaimsUserInfo", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim", "example-claim-alt"}, +					}, +					"alt-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +							"example-claim-alt": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope", "alt-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope", "alt-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  nil, +				UserInfo: map[string]*oidc.ClaimRequest{"example-claim": nil, "example-claim-alt": nil}, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{}, +			map[string]any{oidc.ClaimAuthenticationTime: 123}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": 123, "example-claim-alt": 123, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldGrantRequestedClaimsUserInfoButNotPropagateIDTokenClaims", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			&oidc.ClaimsRequests{ +				IDToken:  nil, +				UserInfo: map[string]*oidc.ClaimRequest{"example-claim": nil}, +			}, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimJWTID: "EXAMPLE", oidc.ClaimFullName: "Not John Smith"}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": 123, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +		{ +			"ShouldIncludeCustomClaimsInIDTokenFromPolicy", +			schema.IdentityProvidersOpenIDConnect{ +				Scopes: map[string]schema.IdentityProvidersOpenIDConnectScope{ +					"example-scope": { +						Claims: []string{"example-claim"}, +					}, +				}, +				ClaimsPolicies: map[string]schema.IdentityProvidersOpenIDConnectClaimsPolicy{ +					"example-policy": { +						IDToken:     []string{"example-claim"}, +						AccessToken: []string{}, +						CustomClaims: map[string]schema.IdentityProvidersOpenIDConnectCustomClaim{ +							"example-claim": { +								Attribute: "example-claim", +							}, +						}, +					}, +				}, +				Clients: []schema.IdentityProvidersOpenIDConnectClient{ +					{ +						ID:           "example-client", +						Scopes:       []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +						ClaimsPolicy: "example-policy", +					}, +				}, +			}, +			[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeGroups, oidc.ScopeEmail, "example-scope"}, +			nil, +			&testDetailer{ +				username: "john", +				groups:   []string{"abc", "123"}, +				name:     "John Smith", +				emails:   []string{"john.smith@authelia.com"}, +				extra: map[string]any{ +					"example-claim": 123, +				}, +			}, +			"", +			[]string{}, +			map[string]any{oidc.ClaimAuthenticationTime: 123}, +			map[string]any{oidc.ClaimAuthenticationTime: 123, oidc.ClaimAudience: []string{"example-client"}, "example-claim": 123}, +			map[string]any{oidc.ClaimEmail: "john.smith@authelia.com", oidc.ClaimEmailVerified: true, "example-claim": 123, oidc.ClaimGroups: []string{"abc", "123"}, oidc.ClaimFullName: "John Smith", oidc.ClaimPreferredUsername: "john", oidc.ClaimUpdatedAt: int64(123123123)}, +			"", +			"", +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			require.Len(t, tc.config.Clients, 1) + +			client := &oidc.RegisteredClient{ +				ID:             tc.config.Clients[0].ID, +				ClaimsStrategy: oidc.NewCustomClaimsStrategy(tc.config.Clients[0], tc.config.Scopes, tc.config.ClaimsPolicies), +				Scopes:         tc.config.Clients[0].Scopes, +			} + +			strategy := oauthelia2.ExactScopeStrategy +			start := time.Unix(123123123, 0) +			ctx := &TestContext{} + +			if tc.err != "" { +				assert.EqualError(t, client.ClaimsStrategy.ValidateClaimsRequests(ctx, strategy, client, tc.requests), tc.err) + +				return +			} + +			assert.NoError(t, client.ClaimsStrategy.ValidateClaimsRequests(ctx, strategy, client, tc.requests)) + +			var ( +				extra    map[string]any +				requests map[string]*oidc.ClaimRequest +			) + +			if tc.requests != nil && tc.requests.IDToken != nil { +				requests = tc.requests.IDToken +			} else { +				requests = make(map[string]*oidc.ClaimRequest) +			} + +			extra = make(map[string]any) + +			err := client.ClaimsStrategy.PopulateIDTokenClaims(ctx, strategy, client, tc.scopes, tc.claims, requests, tc.detailer, start, tc.original, extra) + +			if tc.errIDToken == "" { +				assert.NoError(t, oauthelia2.ErrorToDebugRFC6749Error(err)) +			} else { +				assert.EqualError(t, oauthelia2.ErrorToDebugRFC6749Error(err), tc.errIDToken) +			} + +			assert.Equal(t, tc.expectedIDToken, extra) + +			if tc.requests != nil && tc.requests.UserInfo != nil { +				requests = tc.requests.UserInfo +			} else { +				requests = make(map[string]*oidc.ClaimRequest) +			} + +			extra = make(map[string]any) + +			err = client.ClaimsStrategy.PopulateUserInfoClaims(ctx, strategy, client, tc.scopes, tc.claims, requests, tc.detailer, start, tc.original, extra) + +			if tc.errUserInfo == "" { +				assert.NoError(t, oauthelia2.ErrorToDebugRFC6749Error(err)) +			} else { +				assert.EqualError(t, oauthelia2.ErrorToDebugRFC6749Error(err), tc.errUserInfo) +			} + +			assert.Equal(t, tc.expectedUserInfo, extra) +		}) +	} +} + +func TestGrantScopeAudienceConsent(t *testing.T) { +	testCases := []struct { +		name             string +		ar               oauthelia2.Requester +		consent          *model.OAuth2ConsentSession +		expected         bool +		expectedScope    oauthelia2.Arguments +		expectedAudience oauthelia2.Arguments +	}{ +		{ +			"ShouldGrant", +			&oauthelia2.Request{}, +			&model.OAuth2ConsentSession{ +				GrantedScopes:   []string{"abc"}, +				GrantedAudience: []string{"ad"}, +			}, +			true, +			[]string{"abc"}, +			[]string{"ad"}, +		}, +		{ +			"ShouldNotGrant", +			&oauthelia2.Request{}, +			&model.OAuth2ConsentSession{ +				GrantedScopes:   []string{}, +				GrantedAudience: []string{}, +			}, +			true, +			nil, +			nil, +		}, +		{ +			"ShouldNotGrantNilConsent", +			&oauthelia2.Request{}, +			nil, +			true, +			nil, +			nil, +		}, +		{ +			"ShouldNotGrantNilRequest", +			nil, +			&model.OAuth2ConsentSession{ +				GrantedScopes:   []string{}, +				GrantedAudience: []string{}, +			}, +			false, +			nil, +			nil, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			oidc.GrantScopeAudienceConsent(tc.ar, tc.consent) + +			if tc.expected { +				require.NotNil(t, tc.ar) +				assert.Equal(t, tc.expectedScope, tc.ar.GetGrantedScopes()) +				assert.Equal(t, tc.expectedAudience, tc.ar.GetGrantedAudience()) +			} else { +				assert.Nil(t, tc.ar) +			} +		}) +	} +} + +func TestGetAudienceFromClaims(t *testing.T) { +	testCases := []struct { +		name     string +		have     map[string]any +		expected []string +		ok       bool +	}{ +		{ +			"ShouldNotPanicOnNil", +			nil, +			nil, +			false, +		}, +		{ +			"ShouldReturnNothingWithoutClaim", +			map[string]any{}, +			nil, +			false, +		}, +		{ +			"ShouldReturnClaimString", +			map[string]any{oidc.ClaimAudience: "abc"}, +			[]string{"abc"}, +			true, +		}, +		{ +			"ShouldReturnClaimStringEmpty", +			map[string]any{oidc.ClaimAudience: ""}, +			nil, +			true, +		}, +		{ +			"ShouldReturnClaimSlice", +			map[string]any{oidc.ClaimAudience: []string{"abc"}}, +			[]string{"abc"}, +			true, +		}, +		{ +			"ShouldReturnClaimSliceMulti", +			map[string]any{oidc.ClaimAudience: []string{"abc", "123"}}, +			[]string{"abc", "123"}, +			true, +		}, +		{ +			"ShouldReturnClaimSliceAny", +			map[string]any{oidc.ClaimAudience: []any{"abc"}}, +			[]string{"abc"}, +			true, +		}, +		{ +			"ShouldReturnClaimSliceAnyMulti", +			map[string]any{oidc.ClaimAudience: []any{"abc", "123"}}, +			[]string{"abc", "123"}, +			true, +		}, +		{ +			"ShouldReturnNilInvalidClaim", +			map[string]any{oidc.ClaimAudience: []any{"abc", "123", 11}}, +			nil, +			false, +		}, +	} + +	for _, tc := range testCases { +		t.Run(tc.name, func(t *testing.T) { +			actual, ok := oidc.GetAudienceFromClaims(tc.have) + +			assert.Equal(t, tc.expected, actual) +			assert.Equal(t, tc.ok, ok) +		}) +	} +} + +type testDetailer struct { +	username  string +	groups    []string +	name      string +	emails    []string +	given     string +	family    string +	middle    string +	nickname  string +	profile   string +	picture   string +	website   string +	gender    string +	birthdate string +	info      string +	locale    string +	number    string +	extension string +	address   string +	locality  string +	region    string +	postcode  string +	country   string +	extra     map[string]any +} + +func (t testDetailer) GetGivenName() (given string) { +	return t.given +} + +func (t testDetailer) GetFamilyName() (family string) { +	return t.family +} + +func (t testDetailer) GetMiddleName() (middle string) { +	return t.middle +} + +func (t testDetailer) GetNickname() (nickname string) { +	return t.nickname +} + +func (t testDetailer) GetProfile() (profile string) { +	return t.profile +} + +func (t testDetailer) GetPicture() (picture string) { +	return t.picture +} + +func (t testDetailer) GetWebsite() (website string) { +	return t.website +} + +func (t testDetailer) GetGender() (gender string) { +	return t.gender +} + +func (t testDetailer) GetBirthdate() (birthdate string) { +	return t.birthdate +} + +func (t testDetailer) GetZoneInfo() (info string) { +	return t.info +} + +func (t testDetailer) GetLocale() (locale string) { +	return t.locale +} + +func (t testDetailer) GetPhoneNumber() (number string) { +	return t.number +} + +func (t testDetailer) GetPhoneExtension() (extension string) { +	return t.extension +} + +func (t testDetailer) GetPhoneNumberRFC3966() (number string) { +	return t.number +} + +func (t testDetailer) GetStreetAddress() (address string) { +	return t.address +} + +func (t testDetailer) GetLocality() (locality string) { +	return t.locality +} + +func (t testDetailer) GetRegion() (region string) { +	return t.region +} + +func (t testDetailer) GetPostalCode() (postcode string) { +	return t.postcode +} + +func (t testDetailer) GetCountry() (country string) { +	return t.country +} + +func (t testDetailer) GetUsername() (username string) { +	return t.username +} + +func (t testDetailer) GetGroups() (groups []string) { +	return t.groups +} + +func (t testDetailer) GetDisplayName() (name string) { +	return t.name +} + +func (t testDetailer) GetEmails() (emails []string) { +	return t.emails +} + +func (t testDetailer) GetExtra() (extra map[string]any) { +	return t.extra +} + +var ( +	_ oidc.UserDetailer = (*testDetailer)(nil) +) diff --git a/internal/oidc/client.go b/internal/oidc/client.go index 9c9356f48..e68f817b9 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -2,6 +2,7 @@ package oidc  import (  	"context" +	"net/url"  	"time"  	oauthelia2 "authelia.com/provider/oauth2" @@ -31,6 +32,8 @@ func NewClient(config schema.IdentityProvidersOpenIDConnectClient, c *schema.Ide  		ResponseTypes: config.ResponseTypes,  		ResponseModes: []oauthelia2.ResponseModeType{}, +		ClaimsStrategy: NewCustomClaimsStrategy(config, c.Scopes, c.ClaimsPolicies), +  		RequirePKCE:                config.RequirePKCE || config.PKCEChallengeMethod != "",  		RequirePKCEChallengeMethod: config.PKCEChallengeMethod != "",  		PKCEChallengeMethod:        config.PKCEChallengeMethod, @@ -143,6 +146,10 @@ func (c *RegisteredClient) GetResponseTypes() (types oauthelia2.Arguments) {  	return c.ResponseTypes  } +func (c *RegisteredClient) GetClaimsStrategy() (strategy ClaimsStrategy) { +	return c.ClaimsStrategy +} +  // GetScopes returns the Scopes.  func (c *RegisteredClient) GetScopes() (scopes oauthelia2.Arguments) {  	return c.Scopes @@ -451,16 +458,33 @@ func (c *RegisteredClient) GetPKCEChallengeMethod() (method string) {  }  // GetConsentResponseBody returns the proper consent response body for this session.OIDCWorkflowSession. -func (c *RegisteredClient) GetConsentResponseBody(consent *model.OAuth2ConsentSession) ConsentGetResponseBody { +func (c *RegisteredClient) GetConsentResponseBody(consent *model.OAuth2ConsentSession, form url.Values) ConsentGetResponseBody {  	body := ConsentGetResponseBody{  		ClientID:          c.ID,  		ClientDescription: c.Name,  		PreConfiguration:  c.ConsentPolicy.Mode == ClientConsentModePreConfigured, +		Claims:            []string{}, +		EssentialClaims:   []string{},  	}  	if consent != nil {  		body.Scopes = consent.RequestedScopes  		body.Audience = consent.RequestedAudience + +		var ( +			claims *ClaimsRequests +			err    error +		) + +		if form == nil { +			form, _ = consent.GetForm() +		} + +		if form != nil { +			if claims, err = NewClaimRequests(form); err == nil { +				body.Claims, body.EssentialClaims = claims.ToSlices() +			} +		}  	}  	return body diff --git a/internal/oidc/client_test.go b/internal/oidc/client_test.go index 5ff31cf64..46d5b50c8 100644 --- a/internal/oidc/client_test.go +++ b/internal/oidc/client_test.go @@ -255,7 +255,7 @@ func TestIsAuthenticationLevelSufficient(t *testing.T) {  func TestClient_GetConsentResponseBody(t *testing.T) {  	c := &oidc.RegisteredClient{} -	consentRequestBody := c.GetConsentResponseBody(nil) +	consentRequestBody := c.GetConsentResponseBody(nil, nil)  	assert.Equal(t, "", consentRequestBody.ClientID)  	assert.Equal(t, "", consentRequestBody.ClientDescription)  	assert.Equal(t, []string(nil), consentRequestBody.Scopes) @@ -272,7 +272,7 @@ func TestClient_GetConsentResponseBody(t *testing.T) {  	expectedScopes := []string{oidc.ScopeOpenID, oidc.ScopeGroups}  	expectedAudiences := []string{examplecom} -	consentRequestBody = c.GetConsentResponseBody(consent) +	consentRequestBody = c.GetConsentResponseBody(consent, nil)  	assert.Equal(t, myclient, consentRequestBody.ClientID)  	assert.Equal(t, myclientdesc, consentRequestBody.ClientDescription)  	assert.Equal(t, expectedScopes, consentRequestBody.Scopes) diff --git a/internal/oidc/const.go b/internal/oidc/const.go index d0e483295..ef522c18d 100644 --- a/internal/oidc/const.go +++ b/internal/oidc/const.go @@ -11,6 +11,8 @@ const (  	ScopeOpenID        = "openid"  	ScopeProfile       = "profile"  	ScopeEmail         = "email" +	ScopePhone         = "phone" +	ScopeAddress       = "address"  	ScopeGroups        = "groups"  	ScopeAutheliaBearerAuthz = "authelia.bearer.authz" @@ -29,14 +31,9 @@ const (  	ClaimExpirationTime                      = "exp"  	ClaimAuthenticationTime                  = "auth_time"  	ClaimIssuer                              = valueIss -	ClaimSubject                             = "sub"  	ClaimNonce                               = "nonce"  	ClaimAudience                            = "aud"  	ClaimGroups                              = "groups" -	ClaimFullName                            = "name" -	ClaimPreferredUsername                   = "preferred_username" -	ClaimPreferredEmail                      = "email" -	ClaimEmailVerified                       = "email_verified"  	ClaimAuthorizedParty                     = "azp"  	ClaimAuthenticationContextClassReference = "acr"  	ClaimAuthenticationMethodsReference      = "amr" @@ -44,6 +41,26 @@ const (  	ClaimScope                               = valueScope  	ClaimScopeNonStandard                    = "scp"  	ClaimExtra                               = "ext" +	ClaimSubject                             = "sub" +	ClaimFullName                            = "name" +	ClaimGivenName                           = "given_name" +	ClaimFamilyName                          = "family_name" +	ClaimMiddleName                          = "middle_name" +	ClaimNickname                            = "nickname" +	ClaimPreferredUsername                   = "preferred_username" +	ClaimProfile                             = "profile" +	ClaimPicture                             = "picture" +	ClaimWebsite                             = "website" +	ClaimEmail                               = "email" +	ClaimEmailVerified                       = "email_verified" +	ClaimGender                              = "gender" +	ClaimBirthdate                           = "birthdate" +	ClaimZoneinfo                            = "zoneinfo" +	ClaimLocale                              = "locale" +	ClaimPhoneNumber                         = "phone_number" +	ClaimPhoneNumberVerified                 = "phone_number_verified" +	ClaimAddress                             = "address" +	ClaimUpdatedAt                           = "updated_at"  	ClaimActive                              = "active"  	ClaimUsername                            = "username"  	ClaimTokenIntrospection                  = "token_introspection" @@ -174,6 +191,7 @@ const (  	FormParameterIssuer       = valueIss  	FormParameterPrompt       = "prompt"  	FormParameterMaximumAge   = "max_age" +	FormParameterClaims       = "claims"  )  const ( diff --git a/internal/oidc/discovery.go b/internal/oidc/discovery.go index 3c07d0683..e39ea394a 100644 --- a/internal/oidc/discovery.go +++ b/internal/oidc/discovery.go @@ -44,8 +44,10 @@ func NewOpenIDConnectWellKnownConfiguration(c *schema.IdentityProvidersOpenIDCon  					ScopeOfflineAccess,  					ScopeOpenID,  					ScopeProfile, -					ScopeGroups,  					ScopeEmail, +					ScopeAddress, +					ScopePhone, +					ScopeGroups,  				},  				ClaimsSupported: []string{  					ClaimAuthenticationMethodsReference, @@ -57,15 +59,30 @@ func NewOpenIDConnectWellKnownConfiguration(c *schema.IdentityProvidersOpenIDCon  					ClaimIssuer,  					ClaimJWTID,  					ClaimRequestedAt, -					ClaimSubject,  					ClaimAuthenticationTime,  					ClaimNonce, -					ClaimPreferredEmail, -					ClaimEmailVerified, -					ClaimEmailAlts,  					ClaimGroups, -					ClaimPreferredUsername, +					ClaimSubject,  					ClaimFullName, +					ClaimGivenName, +					ClaimFamilyName, +					ClaimMiddleName, +					ClaimNickname, +					ClaimPreferredUsername, +					ClaimProfile, +					ClaimPicture, +					ClaimWebsite, +					ClaimEmail, +					ClaimEmailVerified, +					ClaimEmailAlts, +					ClaimGender, +					ClaimBirthdate, +					ClaimZoneinfo, +					ClaimLocale, +					ClaimPhoneNumber, +					ClaimPhoneNumberVerified, +					ClaimAddress, +					ClaimUpdatedAt,  				},  				TokenEndpointAuthMethodsSupported: []string{  					ClientAuthMethodClientSecretBasic, @@ -162,6 +179,7 @@ func NewOpenIDConnectWellKnownConfiguration(c *schema.IdentityProvidersOpenIDCon  			RequestParameterSupported:     true,  			RequestURIParameterSupported:  true,  			RequireRequestURIRegistration: true, +			ClaimsParameterSupported:      true,  		},  		OpenIDConnectPromptCreateDiscoveryOptions: &OpenIDConnectPromptCreateDiscoveryOptions{  			PromptValuesSupported: []string{ diff --git a/internal/oidc/discovery_test.go b/internal/oidc/discovery_test.go index f9fa794dc..91c5cd6cf 100644 --- a/internal/oidc/discovery_test.go +++ b/internal/oidc/discovery_test.go @@ -181,12 +181,14 @@ func TestNewOpenIDConnectProvider_GetOpenIDConnectWellKnownConfiguration(t *test  	assert.Len(t, disco.CodeChallengeMethodsSupported, 1)  	assert.Contains(t, disco.CodeChallengeMethodsSupported, oidc.PKCEChallengeMethodSHA256) -	assert.Len(t, disco.ScopesSupported, 5) +	assert.Len(t, disco.ScopesSupported, 7)  	assert.Contains(t, disco.ScopesSupported, oidc.ScopeOpenID)  	assert.Contains(t, disco.ScopesSupported, oidc.ScopeOfflineAccess)  	assert.Contains(t, disco.ScopesSupported, oidc.ScopeProfile)  	assert.Contains(t, disco.ScopesSupported, oidc.ScopeGroups)  	assert.Contains(t, disco.ScopesSupported, oidc.ScopeEmail) +	assert.Contains(t, disco.ScopesSupported, oidc.ScopeAddress) +	assert.Contains(t, disco.ScopesSupported, oidc.ScopePhone)  	assert.Len(t, disco.ResponseModesSupported, 7)  	assert.Contains(t, disco.ResponseModesSupported, oidc.ResponseModeFormPost) @@ -232,7 +234,7 @@ func TestNewOpenIDConnectProvider_GetOpenIDConnectWellKnownConfiguration(t *test  	assert.Equal(t, []string{oidc.SigningAlgRSAUsingSHA256, oidc.SigningAlgNone}, disco.UserinfoSigningAlgValuesSupported)  	assert.Equal(t, []string{oidc.SigningAlgRSAUsingSHA256, oidc.SigningAlgRSAUsingSHA384, oidc.SigningAlgRSAUsingSHA512, oidc.SigningAlgECDSAUsingP256AndSHA256, oidc.SigningAlgECDSAUsingP384AndSHA384, oidc.SigningAlgECDSAUsingP521AndSHA512, oidc.SigningAlgRSAPSSUsingSHA256, oidc.SigningAlgRSAPSSUsingSHA384, oidc.SigningAlgRSAPSSUsingSHA512, oidc.SigningAlgNone}, disco.RequestObjectSigningAlgValuesSupported) -	assert.Len(t, disco.ClaimsSupported, 18) +	assert.Len(t, disco.ClaimsSupported, 33)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAuthenticationMethodsReference)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAudience)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAuthorizedParty) @@ -245,12 +247,27 @@ func TestNewOpenIDConnectProvider_GetOpenIDConnectWellKnownConfiguration(t *test  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimSubject)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAuthenticationTime)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimNonce) -	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPreferredEmail) -	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmailVerified)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmailAlts)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimGroups) -	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPreferredUsername)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimFullName) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimGivenName) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimFamilyName) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimMiddleName) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimNickname) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPreferredUsername) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimProfile) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPicture) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimWebsite) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmail) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmailVerified) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimGender) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimBirthdate) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimZoneinfo) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimLocale) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPhoneNumber) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPhoneNumberVerified) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAddress) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimUpdatedAt)  	assert.Len(t, disco.PromptValuesSupported, 4)  	assert.Contains(t, disco.PromptValuesSupported, oidc.PromptConsent) @@ -295,12 +312,14 @@ func TestNewOpenIDConnectProvider_GetOAuth2WellKnownConfiguration(t *testing.T)  	require.Len(t, disco.CodeChallengeMethodsSupported, 1)  	assert.Equal(t, "S256", disco.CodeChallengeMethodsSupported[0]) -	assert.Len(t, disco.ScopesSupported, 5) -	assert.Contains(t, disco.ScopesSupported, oidc.ScopeOpenID) +	assert.Len(t, disco.ScopesSupported, 7)  	assert.Contains(t, disco.ScopesSupported, oidc.ScopeOfflineAccess) +	assert.Contains(t, disco.ScopesSupported, oidc.ScopeOpenID)  	assert.Contains(t, disco.ScopesSupported, oidc.ScopeProfile) -	assert.Contains(t, disco.ScopesSupported, oidc.ScopeGroups)  	assert.Contains(t, disco.ScopesSupported, oidc.ScopeEmail) +	assert.Contains(t, disco.ScopesSupported, oidc.ScopeAddress) +	assert.Contains(t, disco.ScopesSupported, oidc.ScopePhone) +	assert.Contains(t, disco.ScopesSupported, oidc.ScopeGroups)  	assert.Len(t, disco.ResponseModesSupported, 7)  	assert.Contains(t, disco.ResponseModesSupported, oidc.ResponseModeFormPost) @@ -337,7 +356,7 @@ func TestNewOpenIDConnectProvider_GetOAuth2WellKnownConfiguration(t *testing.T)  	assert.Contains(t, disco.GrantTypesSupported, oidc.GrantTypeClientCredentials)  	assert.Contains(t, disco.GrantTypesSupported, oidc.GrantTypeRefreshToken) -	assert.Len(t, disco.ClaimsSupported, 18) +	assert.Len(t, disco.ClaimsSupported, 33)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAuthenticationMethodsReference)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAudience)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAuthorizedParty) @@ -345,17 +364,32 @@ func TestNewOpenIDConnectProvider_GetOAuth2WellKnownConfiguration(t *testing.T)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimExpirationTime)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimIssuedAt)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimIssuer) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimSubject)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimJWTID)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimRequestedAt) -	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimSubject)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAuthenticationTime)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimNonce) -	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPreferredEmail) -	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmailVerified) -	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmailAlts)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimGroups) -	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPreferredUsername)  	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimFullName) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimGivenName) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimFamilyName) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimMiddleName) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimNickname) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPreferredUsername) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimProfile) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPicture) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimWebsite) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmail) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmailVerified) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimEmailAlts) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimGender) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimBirthdate) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimZoneinfo) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimLocale) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPhoneNumber) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimPhoneNumberVerified) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimAddress) +	assert.Contains(t, disco.ClaimsSupported, oidc.ClaimUpdatedAt)  }  func TestNewOpenIDConnectProvider_GetOpenIDConnectWellKnownConfigurationWithPlainPKCE(t *testing.T) { @@ -472,7 +506,7 @@ func TestNewOpenIDConnectWellKnownConfiguration_Copy(t *testing.T) {  			RequestParameterSupported:                 true,  			RequestURIParameterSupported:              true,  			RequireRequestURIRegistration:             true, -			ClaimsParameterSupported:                  false, +			ClaimsParameterSupported:                  true,  		},  		OpenIDConnectFrontChannelLogoutDiscoveryOptions: &oidc.OpenIDConnectFrontChannelLogoutDiscoveryOptions{  			FrontChannelLogoutSupported:        false, diff --git a/internal/oidc/session.go b/internal/oidc/session.go index b747c5437..b2a10aa1a 100644 --- a/internal/oidc/session.go +++ b/internal/oidc/session.go @@ -28,9 +28,9 @@ func NewSession() (session *Session) {  	}  } -// NewSessionWithAuthorizeRequest uses details from an AuthorizeRequester to generate an OpenIDSession. -func NewSessionWithAuthorizeRequest(ctx Context, issuer *url.URL, kid, username string, amr []string, extra map[string]any, -	authTime time.Time, consent *model.OAuth2ConsentSession, requester oauthelia2.AuthorizeRequester) (session *Session) { +// NewSessionWithRequester uses details from a Requester to generate an OpenIDSession. +func NewSessionWithRequester(ctx Context, issuer *url.URL, kid, username string, amr []string, extra map[string]any, +	authTime time.Time, consent *model.OAuth2ConsentSession, requester oauthelia2.Requester, claims *ClaimsRequests) (session *Session) {  	if extra == nil {  		extra = map[string]any{}  	} @@ -61,6 +61,8 @@ func NewSessionWithAuthorizeRequest(ctx Context, issuer *url.URL, kid, username  		ClientID:              requester.GetClient().GetID(),  		ExcludeNotBeforeClaim: false,  		AllowedTopLevelClaims: nil, +		ClaimRequests:         claims, +		GrantedClaims:         consent.GrantedClaims,  		Extra:                 map[string]any{},  	} @@ -74,13 +76,15 @@ func NewSessionWithAuthorizeRequest(ctx Context, issuer *url.URL, kid, username  type Session struct {  	*openid.DefaultSession `json:"id_token"` -	ChallengeID           uuid.NullUUID  `json:"challenge_id"` -	KID                   string         `json:"kid"` -	ClientID              string         `json:"client_id"` -	ClientCredentials     bool           `json:"client_credentials"` -	ExcludeNotBeforeClaim bool           `json:"exclude_nbf_claim"` -	AllowedTopLevelClaims []string       `json:"allowed_top_level_claims"` -	Extra                 map[string]any `json:"extra"` +	ChallengeID           uuid.NullUUID   `json:"challenge_id"` +	KID                   string          `json:"kid"` +	ClientID              string          `json:"client_id"` +	ClientCredentials     bool            `json:"client_credentials"` +	ExcludeNotBeforeClaim bool            `json:"exclude_nbf_claim"` +	AllowedTopLevelClaims []string        `json:"allowed_top_level_claims"` +	ClaimRequests         *ClaimsRequests `json:"claim_requests,omitempty"` +	GrantedClaims         []string        `json:"granted_claims,omitempty"` +	Extra                 map[string]any  `json:"extra"`  }  // GetChallengeID returns the challenge id. diff --git a/internal/oidc/types.go b/internal/oidc/types.go index 387405d23..fcdcfff68 100644 --- a/internal/oidc/types.go +++ b/internal/oidc/types.go @@ -15,6 +15,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/model"  	"github.com/authelia/authelia/v4/internal/random"  	"github.com/authelia/authelia/v4/internal/storage" @@ -76,7 +77,8 @@ type RegisteredClient struct {  	ResponseTypes []string  	ResponseModes []oauthelia2.ResponseModeType -	Lifespans schema.IdentityProvidersOpenIDConnectLifespan +	Lifespans      schema.IdentityProvidersOpenIDConnectLifespan +	ClaimsStrategy ClaimsStrategy  	AuthorizationSignedResponseAlg              string  	AuthorizationSignedResponseKeyID            string @@ -123,7 +125,8 @@ type Client interface {  	GetName() (name string)  	GetSectorIdentifierURI() (sector string) -	GetAuthorizationSignedResponseAlg() (alg string) +	GetClaimsStrategy() (strategy ClaimsStrategy) +  	GetAuthorizationSignedResponseKeyID() (kid string)  	GetIDTokenSignedResponseAlg() (alg string) @@ -150,7 +153,7 @@ type Client interface {  	ValidateResponseModePolicy(r oauthelia2.AuthorizeRequester) (err error) -	GetConsentResponseBody(consent *model.OAuth2ConsentSession) (body ConsentGetResponseBody) +	GetConsentResponseBody(consent *model.OAuth2ConsentSession, form url.Values) (body ConsentGetResponseBody)  	GetConsentPolicy() ClientConsentPolicy  	IsAuthenticationLevelSufficient(level authentication.Level, subject authorization.Subject) (sufficient bool)  	GetAuthorizationPolicyRequiredLevel(subject authorization.Subject) (level authorization.Level) @@ -174,6 +177,7 @@ type Context interface {  	GetRandom() (random random.Provider)  	GetConfiguration() (config schema.Configuration)  	GetJWTWithTimeFuncOption() (option jwt.ParserOption) +	GetProviderUserAttributeResolver() expression.UserAttributeResolver  	context.Context  } @@ -224,6 +228,26 @@ type UserDetailer interface {  	GetGroups() (groups []string)  	GetDisplayName() (name string)  	GetEmails() (emails []string) +	GetGivenName() (given string) +	GetFamilyName() (family string) +	GetMiddleName() (middle string) +	GetNickname() (nickname string) +	GetProfile() (profile string) +	GetPicture() (picture string) +	GetWebsite() (website string) +	GetGender() (gender string) +	GetBirthdate() (birthdate string) +	GetZoneInfo() (info string) +	GetLocale() (locale string) +	GetPhoneNumber() (number string) +	GetPhoneExtension() (extension string) +	GetPhoneNumberRFC3966() (number string) +	GetStreetAddress() (address string) +	GetLocality() (locality string) +	GetRegion() (region string) +	GetPostalCode() (postcode string) +	GetCountry() (country string) +	GetExtra() (extra map[string]any)  }  // ConsentGetResponseBody schema of the response body of the consent GET endpoint. @@ -233,14 +257,17 @@ type ConsentGetResponseBody struct {  	Scopes            []string `json:"scopes"`  	Audience          []string `json:"audience"`  	PreConfiguration  bool     `json:"pre_configuration"` +	Claims            []string `json:"claims"` +	EssentialClaims   []string `json:"essential_claims"`  }  // ConsentPostRequestBody schema of the request body of the consent POST endpoint.  type ConsentPostRequestBody struct { -	ConsentID    string `json:"id"` -	ClientID     string `json:"client_id"` -	Consent      bool   `json:"consent"` -	PreConfigure bool   `json:"pre_configure"` +	ConsentID    string   `json:"id"` +	ClientID     string   `json:"client_id"` +	Consent      bool     `json:"consent"` +	PreConfigure bool     `json:"pre_configure"` +	Claims       []string `json:"claims"`  }  // ConsentPostResponseBody schema of the response body of the consent POST endpoint. diff --git a/internal/oidc/types_test.go b/internal/oidc/types_test.go index 71aa16b64..87c639689 100644 --- a/internal/oidc/types_test.go +++ b/internal/oidc/types_test.go @@ -14,6 +14,7 @@ import (  	"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/model"  	"github.com/authelia/authelia/v4/internal/oidc"  	"github.com/authelia/authelia/v4/internal/random" @@ -69,7 +70,43 @@ func TestNewSessionWithAuthorizeRequest(t *testing.T) {  	ctx.Clock = clock.NewFixed(time.Unix(10000000000, 0)) -	session := oidc.NewSessionWithAuthorizeRequest(ctx, MustParseRequestURI(issuer), "primary", "john", amr, extra, authAt, consent, request) +	session := oidc.NewSessionWithRequester(ctx, MustParseRequestURI(issuer), "primary", "john", amr, extra, authAt, consent, request, nil) + +	require.NotNil(t, session) +	require.NotNil(t, session.Extra) +	require.NotNil(t, session.Headers) +	require.NotNil(t, session.Headers.Extra) +	require.NotNil(t, session.Claims) +	require.NotNil(t, session.Claims.Extra) +	require.NotNil(t, session.Claims.AuthenticationMethodsReferences) + +	assert.Equal(t, subject.String(), session.Subject) +	assert.Equal(t, "example", session.ClientID) +	assert.Greater(t, session.Claims.IssuedAt.Unix(), authAt.Unix()) +	assert.Equal(t, "john", session.Username) + +	assert.Equal(t, "abc123xyzauthelia", session.Claims.Nonce) +	assert.Equal(t, subject.String(), session.Claims.Subject) +	assert.Equal(t, amr, session.Claims.AuthenticationMethodsReferences) +	assert.Equal(t, authAt, session.Claims.AuthTime) +	assert.Equal(t, requested, session.Claims.RequestedAt) +	assert.Equal(t, issuer, session.Claims.Issuer) +	assert.Equal(t, "john", session.Claims.Extra[oidc.ClaimPreferredUsername]) + +	assert.Equal(t, "primary", session.Headers.Get(oidc.JWTHeaderKeyIdentifier)) + +	claims := &oidc.ClaimsRequests{ +		IDToken: map[string]*oidc.ClaimRequest{ +			oidc.ClaimSubject:  {}, +			oidc.ClaimFullName: {}, +		}, +		UserInfo: map[string]*oidc.ClaimRequest{ +			oidc.ClaimEmail:       {}, +			oidc.ClaimPhoneNumber: {}, +		}, +	} + +	session = oidc.NewSessionWithRequester(ctx, MustParseRequestURI(issuer), "primary", "john", amr, extra, authAt, consent, request, claims)  	require.NotNil(t, session)  	require.NotNil(t, session.Extra) @@ -99,7 +136,7 @@ func TestNewSessionWithAuthorizeRequest(t *testing.T) {  		RequestedAt: requested,  	} -	session = oidc.NewSessionWithAuthorizeRequest(ctx, MustParseRequestURI(issuer), "primary", "john", nil, nil, authAt, consent, request) +	session = oidc.NewSessionWithRequester(ctx, MustParseRequestURI(issuer), "primary", "john", nil, nil, authAt, consent, request, nil)  	require.NotNil(t, session)  	require.NotNil(t, session.Claims) @@ -133,6 +170,10 @@ func (m *TestContext) GetConfiguration() (config schema.Configuration) {  	return m.Config  } +func (m *TestContext) GetProviderUserAttributeResolver() expression.UserAttributeResolver { +	return &expression.UserAttributes{} +} +  // IssuerURL returns the MockIssuerURL.  func (m *TestContext) RootURL() (issuerURL *url.URL) {  	if m.IssuerURLFunc != nil { diff --git a/internal/oidc/util.go b/internal/oidc/util.go index 3b3b22d12..11841088d 100644 --- a/internal/oidc/util.go +++ b/internal/oidc/util.go @@ -20,7 +20,11 @@ import (  // IsPushedAuthorizedRequest returns true if the requester has a PushedAuthorizationRequest redirect_uri value.  func IsPushedAuthorizedRequest(r oauthelia2.Requester, prefix string) bool { -	return strings.HasPrefix(r.GetRequestForm().Get(FormParameterRequestURI), prefix) +	return IsPushedAuthorizedRequestForm(r.GetRequestForm(), prefix) +} + +func IsPushedAuthorizedRequestForm(form url.Values, prefix string) bool { +	return strings.HasPrefix(form.Get(FormParameterRequestURI), prefix)  }  // SortedSigningAlgs is a sorting type which allows the use of sort.Sort to order a list of OAuth 2.0 Signing Algs. @@ -466,3 +470,52 @@ func getSectorIdentifierURICache(ctx ClientContext, cache map[string][]string, s  	return redirectURIs, nil  } + +func float64Match(expected float64, value any, values []any) (ok bool) { +	var f float64 + +	if value != nil { +		if f, ok = float64As(value); ok { +			return expected == f +		} +	} + +	for _, v := range values { +		if f, ok = float64As(v); ok && expected == f { +			return true +		} +	} + +	return false +} + +func float64As(value any) (float64, bool) { +	switch v := value.(type) { +	case float64: +		return v, true +	case float32: +		return float64(v), true +	case int64: +		return float64(v), true +	case int32: +		return float64(v), true +	case int16: +		return float64(v), true +	case int8: +		return float64(v), true +	case int: +		return float64(v), true +	case uint64: +		return float64(v), true +	case uint32: +		return float64(v), true +	case uint16: +		return float64(v), true +	case uint8: +		return float64(v), true +	case uint: +		return float64(v), true +	default: +		return 0, false +	} +} diff --git a/internal/random/cryptographical_test.go b/internal/random/cryptographical_test.go index f3e703487..504c47863 100644 --- a/internal/random/cryptographical_test.go +++ b/internal/random/cryptographical_test.go @@ -55,7 +55,7 @@ func TestCryptographical(t *testing.T) {  	assert.Equal(t, 0, i)  	bi := p.Int(big.NewInt(999)) -	assert.Greater(t, bi.Int64(), int64(0)) +	assert.GreaterOrEqual(t, bi.Int64(), int64(0))  	assert.Less(t, bi.Int64(), int64(999))  	bi = p.Int(nil) diff --git a/internal/server/locales/en/consent.json b/internal/server/locales/en/consent.json new file mode 100644 index 000000000..915d16559 --- /dev/null +++ b/internal/server/locales/en/consent.json @@ -0,0 +1,37 @@ +{ +	"Claim": "Claim {{name}}", +	"Scope": "Scope {{name}}", +	"scopes": { +		"openid": "Use OpenID to verify your identity", +		"offline_access": "Automatically refresh these permissions without user interaction", +		"profile": "Access your profile information", +		"groups": "Access your group memberships", +		"email": "Access your email addresses", +		"phone": "Access your phone number", +		"address": "Access your address", +		"authelia.bearer.authz": "Access protected resources logged in as you" +	}, +	"claims": { +		"sub": "Unique Identifier", +		"name": "Display Name", +		"given_name": "Given Name", +		"family_name": "Family Name", +		"middle_name": "Middle Name", +		"nickname": "Nickname", +		"preferred_username": "Preferred Username", +		"profile": "Profile URL", +		"picture": "Picture URL", +		"website": "Website URL", +		"email": "E-mail Address", +		"email_verified": "E-mail Address (Verified)", +		"gender": "Gender", +		"birthdate": "Birthdate", +		"zoneinfo": "Timezone", +		"locale": "Locale / Language", +		"phone_number": "Phone Number", +		"phone_number_verified": "Phone Number (Verified)", +		"address": "Postal Address", +		"updated_at": "Profile Update Time", +		"groups": "Group Membership" +	} +} diff --git a/internal/server/locales/en/portal.json b/internal/server/locales/en/portal.json index 90cff4129..4c812c103 100644 --- a/internal/server/locales/en/portal.json +++ b/internal/server/locales/en/portal.json @@ -59,7 +59,6 @@  	"Reset password?": "Reset password?",  	"Reset": "Reset",  	"Retry": "Retry", -	"Scope": "Scope {{name}}",  	"Secret": "Secret",  	"Security Key - WebAuthn": "Security Key - WebAuthn",  	"Security Key": "Security Key", diff --git a/internal/storage/migrations/mysql/V0017.OAuth2Claims.down.sql b/internal/storage/migrations/mysql/V0017.OAuth2Claims.down.sql new file mode 100644 index 000000000..367529380 --- /dev/null +++ b/internal/storage/migrations/mysql/V0017.OAuth2Claims.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE oauth2_consent_session +    DROP COLUMN granted_claims; + +ALTER TABLE oauth2_consent_preconfiguration +    DROP COLUMN requested_claims, +    DROP COLUMN signature_claims, +    DROP COLUMN granted_claims; diff --git a/internal/storage/migrations/mysql/V0017.OAuth2Claims.up.sql b/internal/storage/migrations/mysql/V0017.OAuth2Claims.up.sql new file mode 100644 index 000000000..f453ca463 --- /dev/null +++ b/internal/storage/migrations/mysql/V0017.OAuth2Claims.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE oauth2_consent_session +    ADD COLUMN granted_claims TEXT NULL; + +ALTER TABLE oauth2_consent_preconfiguration +    ADD COLUMN requested_claims TEXT, +    ADD COLUMN signature_claims CHAR(64) NULL, +    ADD COLUMN granted_claims TEXT; diff --git a/internal/storage/migrations/postgres/V0017.OAuth2Claims.down.sql b/internal/storage/migrations/postgres/V0017.OAuth2Claims.down.sql new file mode 100644 index 000000000..367529380 --- /dev/null +++ b/internal/storage/migrations/postgres/V0017.OAuth2Claims.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE oauth2_consent_session +    DROP COLUMN granted_claims; + +ALTER TABLE oauth2_consent_preconfiguration +    DROP COLUMN requested_claims, +    DROP COLUMN signature_claims, +    DROP COLUMN granted_claims; diff --git a/internal/storage/migrations/postgres/V0017.OAuth2Claims.up.sql b/internal/storage/migrations/postgres/V0017.OAuth2Claims.up.sql new file mode 100644 index 000000000..d51605893 --- /dev/null +++ b/internal/storage/migrations/postgres/V0017.OAuth2Claims.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE oauth2_consent_session +    ADD COLUMN granted_claims TEXT NULL; + +ALTER TABLE oauth2_consent_preconfiguration +    ADD COLUMN requested_claims TEXT NULL, +    ADD COLUMN signature_claims CHAR(64) NULL, +    ADD COLUMN granted_claims TEXT DEFAULT ''; diff --git a/internal/storage/migrations/sqlite/V0017.OAuth2Claims.down.sql b/internal/storage/migrations/sqlite/V0017.OAuth2Claims.down.sql new file mode 100644 index 000000000..360552a1b --- /dev/null +++ b/internal/storage/migrations/sqlite/V0017.OAuth2Claims.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE oauth2_consent_session +    DROP COLUMN granted_claims; + +ALTER TABLE oauth2_consent_preconfiguration DROP COLUMN requested_claims; +ALTER TABLE oauth2_consent_preconfiguration DROP COLUMN signature_claims; +ALTER TABLE oauth2_consent_preconfiguration DROP COLUMN granted_claims; diff --git a/internal/storage/migrations/sqlite/V0017.OAuth2Claims.up.sql b/internal/storage/migrations/sqlite/V0017.OAuth2Claims.up.sql new file mode 100644 index 000000000..44bf7c1bc --- /dev/null +++ b/internal/storage/migrations/sqlite/V0017.OAuth2Claims.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE oauth2_consent_session +    ADD COLUMN granted_claims TEXT NULL; + +ALTER TABLE oauth2_consent_preconfiguration ADD COLUMN requested_claims TEXT NULL; +ALTER TABLE oauth2_consent_preconfiguration ADD COLUMN signature_claims CHAR(64) NULL; +ALTER TABLE oauth2_consent_preconfiguration ADD COLUMN granted_claims TEXT DEFAULT ''; diff --git a/internal/storage/migrations_test.go b/internal/storage/migrations_test.go index ab47238da..32c1dcc82 100644 --- a/internal/storage/migrations_test.go +++ b/internal/storage/migrations_test.go @@ -11,7 +11,7 @@ import (  const (  	// This is the latest schema version for the purpose of tests. -	LatestVersion = 16 +	LatestVersion = 17  )  func TestShouldObtainCorrectMigrations(t *testing.T) { diff --git a/internal/storage/sql_provider.go b/internal/storage/sql_provider.go index b5e212a28..c458722be 100644 --- a/internal/storage/sql_provider.go +++ b/internal/storage/sql_provider.go @@ -976,7 +976,8 @@ func (p *SQLProvider) SaveOAuth2ConsentPreConfiguration(ctx context.Context, con  	case providerPostgres:  		if err = p.db.GetContext(ctx, &insertedID, p.sqlInsertOAuth2ConsentPreConfiguration,  			config.ClientID, config.Subject, config.CreatedAt, config.ExpiresAt, -			config.Revoked, config.Scopes, config.Audience); err != nil { +			config.Revoked, config.Scopes, config.Audience, +			config.RequestedClaims, config.SignatureClaims, config.GrantedClaims); err != nil {  			return -1, fmt.Errorf("error inserting oauth2 consent pre-configuration for subject '%s' with client id '%s' and scopes '%s': %w", config.Subject.String(), config.ClientID, strings.Join(config.Scopes, " "), err)  		} @@ -986,7 +987,8 @@ func (p *SQLProvider) SaveOAuth2ConsentPreConfiguration(ctx context.Context, con  		if result, err = p.db.ExecContext(ctx, p.sqlInsertOAuth2ConsentPreConfiguration,  			config.ClientID, config.Subject, config.CreatedAt, config.ExpiresAt, -			config.Revoked, config.Scopes, config.Audience); err != nil { +			config.Revoked, config.Scopes, config.Audience, +			config.RequestedClaims, config.SignatureClaims, config.GrantedClaims); err != nil {  			return -1, fmt.Errorf("error inserting oauth2 consent pre-configuration for subject '%s' with client id '%s' and scopes '%s': %w", config.Subject.String(), config.ClientID, strings.Join(config.Scopes, " "), err)  		} @@ -1014,7 +1016,7 @@ func (p *SQLProvider) SaveOAuth2ConsentSession(ctx context.Context, consent *mod  	if _, err = p.db.ExecContext(ctx, p.sqlInsertOAuth2ConsentSession,  		consent.ChallengeID, consent.ClientID, consent.Subject, consent.Authorized, consent.Granted,  		consent.RequestedAt, consent.RespondedAt, consent.Form, -		consent.RequestedScopes, consent.GrantedScopes, consent.RequestedAudience, consent.GrantedAudience, consent.PreConfiguration); err != nil { +		consent.RequestedScopes, consent.GrantedScopes, consent.RequestedAudience, consent.GrantedAudience, consent.GrantedClaims, consent.PreConfiguration); err != nil {  		return fmt.Errorf("error inserting oauth2 consent session with challenge id '%s' for subject '%s': %w", consent.ChallengeID.String(), consent.Subject.UUID.String(), err)  	} @@ -1032,7 +1034,7 @@ func (p *SQLProvider) SaveOAuth2ConsentSessionSubject(ctx context.Context, conse  // SaveOAuth2ConsentSessionResponse updates an OAuth2.0 consent session in the storage provider with the response.  func (p *SQLProvider) SaveOAuth2ConsentSessionResponse(ctx context.Context, consent *model.OAuth2ConsentSession, authorized bool) (err error) { -	if _, err = p.db.ExecContext(ctx, p.sqlUpdateOAuth2ConsentSessionResponse, authorized, consent.GrantedScopes, consent.GrantedAudience, consent.PreConfiguration, consent.ID); err != nil { +	if _, err = p.db.ExecContext(ctx, p.sqlUpdateOAuth2ConsentSessionResponse, authorized, consent.GrantedScopes, consent.GrantedAudience, consent.GrantedClaims, consent.PreConfiguration, consent.ID); err != nil {  		return fmt.Errorf("error updating oauth2 consent session (authorized  '%t') with id '%d' and challenge id '%s' for subject '%s': %w", authorized, consent.ID, consent.ChallengeID, consent.Subject.UUID, err)  	} diff --git a/internal/storage/sql_provider_queries.go b/internal/storage/sql_provider_queries.go index f6a415f8b..8fe0787c0 100644 --- a/internal/storage/sql_provider_queries.go +++ b/internal/storage/sql_provider_queries.go @@ -315,30 +315,30 @@ const (  const (  	queryFmtSelectOAuth2ConsentPreConfigurations = ` -		SELECT id, client_id, subject, created_at, expires_at, revoked, scopes, audience +		SELECT id, client_id, subject, created_at, expires_at, revoked, scopes, audience, requested_claims, signature_claims, granted_claims  		FROM %s  		WHERE client_id = ? AND subject = ? AND  			  revoked = FALSE AND (expires_at IS NULL OR expires_at >= CURRENT_TIMESTAMP);`  	queryFmtInsertOAuth2ConsentPreConfiguration = ` -		INSERT INTO %s (client_id, subject, created_at, expires_at, revoked, scopes, audience) -		VALUES(?, ?, ?, ?, ?, ?, ?);` +		INSERT INTO %s (client_id, subject, created_at, expires_at, revoked, scopes, audience, requested_claims, signature_claims, granted_claims) +		VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`  	queryFmtInsertOAuth2ConsentPreConfigurationPostgreSQL = ` -		INSERT INTO %s (client_id, subject, created_at, expires_at, revoked, scopes, audience) -		VALUES($1, $2, $3, $4, $5, $6, $7) +		INSERT INTO %s (client_id, subject, created_at, expires_at, revoked, scopes, audience, requested_claims, signature_claims, granted_claims) +		VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)  		RETURNING id;`  	queryFmtSelectOAuth2ConsentSessionByChallengeID = `  		SELECT id, challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, -		form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration +		form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, granted_claims, preconfiguration  		FROM %s  		WHERE challenge_id = ?;`  	queryFmtInsertOAuth2ConsentSession = `  		INSERT INTO %s (challenge_id, client_id, subject, authorized, granted, requested_at, responded_at, -		form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, preconfiguration) -		VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` +		form_data, requested_scopes, granted_scopes, requested_audience, granted_audience, granted_claims, preconfiguration) +		VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`  	queryFmtUpdateOAuth2ConsentSessionSubject = `  		UPDATE %s @@ -347,7 +347,7 @@ const (  	queryFmtUpdateOAuth2ConsentSessionResponse = `  		UPDATE %s -		SET authorized = ?, responded_at = CURRENT_TIMESTAMP, granted_scopes = ?, granted_audience = ?, preconfiguration = ? +		SET authorized = ?, responded_at = CURRENT_TIMESTAMP, granted_scopes = ?, granted_audience = ?, granted_claims = ?, preconfiguration = ?  		WHERE id = ? AND responded_at IS NULL;`  	queryFmtUpdateOAuth2ConsentSessionGranted = ` diff --git a/internal/suites/example/compose/oidc-client/docker-compose.yml b/internal/suites/example/compose/oidc-client/docker-compose.yml index abf412626..d1ae2e5cb 100644 --- a/internal/suites/example/compose/oidc-client/docker-compose.yml +++ b/internal/suites/example/compose/oidc-client/docker-compose.yml @@ -1,7 +1,7 @@  ---  services:    oidc-client: -    image: 'ghcr.io/authelia/oidc-tester-app:master-c888009' +    image: 'ghcr.io/authelia/oidc-tester-app:master-c3235cd'      command: '/entrypoint.sh'      depends_on:        - 'authelia-backend' diff --git a/internal/suites/scenario_oidc_test.go b/internal/suites/scenario_oidc_test.go index 515ae7be3..e0fd16c39 100644 --- a/internal/suites/scenario_oidc_test.go +++ b/internal/suites/scenario_oidc_test.go @@ -120,7 +120,7 @@ func (s *OIDCScenario) TestShouldAuthorizeAccessToOIDCApp() {  		{oidc.ClaimFullName, "", "John Doe"},  		{oidc.ClaimPreferredUsername, "", "john"},  		{oidc.ClaimGroups, "", "admins, dev"}, -		{oidc.ClaimPreferredEmail, "", "john.doe@authelia.com"}, +		{oidc.ClaimEmail, "", "john.doe@authelia.com"},  		{oidc.ClaimEmailVerified, "", rBoolean},  	} diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index f115988d4..4782eb3e5 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"], +        ns: ["portal", "settings", "consent"],          defaultNS: "portal",          fallbackLng: {              default: ["en"], diff --git a/web/src/services/ConsentOpenIDConnect.ts b/web/src/services/ConsentOpenIDConnect.ts index b22916fb2..3bc2afbc2 100644 --- a/web/src/services/ConsentOpenIDConnect.ts +++ b/web/src/services/ConsentOpenIDConnect.ts @@ -6,6 +6,7 @@ interface ConsentPostRequestBody {      client_id: string;      consent: boolean;      pre_configure: boolean; +    claims?: string[];  }  interface ConsentPostResponseBody { @@ -18,18 +19,21 @@ export interface ConsentGetResponseBody {      scopes: string[];      audience: string[];      pre_configuration: boolean; +    claims: string[] | null; +    essential_claims: string[] | null;  }  export function getConsentResponse(consentID: string) {      return Get<ConsentGetResponseBody>(ConsentPath + "?id=" + consentID);  } -export function acceptConsent(preConfigure: boolean, clientID: string, consentID: string | null) { +export function acceptConsent(preConfigure: boolean, clientID: string, consentID: string | null, claims: string[]) {      const body: ConsentPostRequestBody = {          id: consentID === null ? undefined : consentID,          client_id: clientID,          consent: true,          pre_configure: preConfigure, +        claims: claims,      };      return Post<ConsentPostResponseBody>(ConsentPath, body);  } @@ -44,6 +48,22 @@ export function rejectConsent(clientID: string, consentID: string | null) {      return Post<ConsentPostResponseBody>(ConsentPath, body);  } +export function formatScope(scope: string, fallback: string): string { +    if (!scope.startsWith("scopes.") && scope !== "") { +        return scope; +    } else { +        return getScopeDescription(fallback); +    } +} + +export function formatClaim(claim: string, fallback: string): string { +    if (!claim.startsWith("claims.") && claim !== "") { +        return claim; +    } else { +        return getClaimDescription(fallback); +    } +} +  export function getScopeDescription(scope: string): string {      switch (scope) {          case "openid": @@ -62,3 +82,37 @@ export function getScopeDescription(scope: string): string {              return scope;      }  } + +export function getClaimDescription(claim: string): string { +    switch (claim) { +        case "name": +            return "Display Name"; +        case "sub": +            return "Unique Identifier"; +        case "zoneinfo": +            return "Timezone"; +        case "locale": +            return "Locale / Language"; +        case "updated_at": +            return "Information Updated Time"; +        case "profile": +        case "website": +        case "picture": +            return `${setClaimCase(claim)} URL`; +        default: +            return setClaimCase(claim); +    } +} + +function setClaimCase(claim: string): string { +    claim = (claim.charAt(0).toUpperCase() + claim.slice(1)).replace("_verified", " (Verified)").replace("_", " "); + +    for (let i = 0; i < claim.length; i++) { +        const j = i + 1; + +        if (claim[i] === " " && j < claim.length) { +            claim.charAt(j).toUpperCase(); +        } +    } +    return claim; +} diff --git a/web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx b/web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx index cc1071bc4..955cf2757 100644 --- a/web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx +++ b/web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx @@ -1,7 +1,8 @@ -import React, { Fragment, ReactNode, useEffect, useState } from "react"; +import React, { Fragment, ReactNode, useCallback, useEffect, useState } from "react"; -import { AccountBox, Autorenew, CheckBox, Contacts, Drafts, Group, LockOpen } from "@mui/icons-material"; +import { AccountBox, Autorenew, Contacts, Drafts, Group, LockOpen, Policy } from "@mui/icons-material";  import { +    Box,      Button,      Checkbox,      FormControlLabel, @@ -27,8 +28,9 @@ import { UserInfo } from "@models/UserInfo";  import {      ConsentGetResponseBody,      acceptConsent, +    formatClaim, +    formatScope,      getConsentResponse, -    getScopeDescription,      rejectConsent,  } from "@services/ConsentOpenIDConnect";  import { AutheliaState } from "@services/State"; @@ -54,12 +56,12 @@ function scopeNameToAvatar(id: string) {          case "authelia.bearer.authz":              return <LockOpen />;          default: -            return <CheckBox />; +            return <Policy />;      }  }  const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) => { -    const { t: translate } = useTranslation(); +    const { t: translate } = useTranslation(["portal", "consent"]);      const { createErrorNotification, resetNotification } = useNotifications();      const navigate = useNavigate(); @@ -69,6 +71,7 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>      const [response, setResponse] = useState<ConsentGetResponseBody>();      const [error, setError] = useState<any>(undefined); +    const [claims, setClaims] = useState<string>("");      const [preConfigure, setPreConfigure] = useState(false);      const styles = useStyles(); @@ -82,6 +85,7 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>              getConsentResponse(consentID)                  .then((r) => {                      setResponse(r); +                    setClaims(JSON.stringify(r.claims));                  })                  .catch((error) => {                      setError(error); @@ -101,7 +105,7 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>          if (!response) {              return;          } -        const res = await acceptConsent(preConfigure, response.client_id, consentID); +        const res = await acceptConsent(preConfigure, response.client_id, consentID, JSON.parse(claims));          if (res.redirect_uri) {              redirect(res.redirect_uri);          } else { @@ -121,6 +125,39 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>          }      }; +    const handleClaimCheckboxOnChange = (event: React.ChangeEvent<HTMLInputElement>) => { +        setClaims((prevState) => { +            const value = event.target.value; +            const arrClaims: string[] = JSON.parse(prevState); +            const checking = !arrClaims.includes(event.target.value); + +            if (checking) { +                if (!arrClaims.includes(value)) { +                    arrClaims.push(value); +                } +            } else { +                const i = arrClaims.indexOf(value); + +                if (i > -1) { +                    arrClaims.splice(i, 1); +                } +            } + +            return JSON.stringify(arrClaims); +        }); +    }; + +    const claimChecked = useCallback( +        (claim: string) => { +            const arrClaims: string[] = JSON.parse(claims); + +            return arrClaims.includes(claim); +        }, +        [claims], +    ); + +    const hasClaims = response?.essential_claims || response?.claims; +      return (          <ComponentOrLoading ready={response !== undefined}>              <LoginLayout @@ -130,7 +167,7 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>              >                  <Grid container alignItems={"center"} justifyContent="center">                      <Grid size={{ xs: 12 }}> -                        <div> +                        <Box>                              <Tooltip                                  title={                                      translate("Client ID", { client_id: response?.client_id }) || @@ -143,25 +180,67 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>                                          : response?.client_id}                                  </Typography>                              </Tooltip> -                        </div> +                        </Box>                      </Grid>                      <Grid size={{ xs: 12 }}> -                        <div>{translate("The above application is requesting the following permissions")}:</div> +                        <Box>{translate("The above application is requesting the following permissions")}:</Box>                      </Grid>                      <Grid size={{ xs: 12 }}> -                        <div className={styles.scopesListContainer}> +                        <Box className={styles.scopesListContainer}>                              <List className={styles.scopesList}>                                  {response?.scopes.map((scope: string) => ( -                                    <Tooltip title={translate("Scope", { name: scope })}> +                                    <Tooltip title={translate("Scope", { name: scope, ns: "consent" })}>                                          <ListItem id={"scope-" + scope} dense>                                              <ListItemIcon>{scopeNameToAvatar(scope)}</ListItemIcon> -                                            <ListItemText primary={translate(getScopeDescription(scope))} /> +                                            <ListItemText +                                                primary={formatScope( +                                                    translate(`scopes.${scope}`, { ns: "consent" }), +                                                    scope, +                                                )} +                                            />                                          </ListItem>                                      </Tooltip>                                  ))}                              </List> -                        </div> +                        </Box>                      </Grid> +                    {hasClaims ? ( +                        <Grid size={{ xs: 12 }}> +                            <Box className={styles.claimsListContainer}> +                                <List className={styles.claimsList}> +                                    {response?.essential_claims?.map((claim: string) => ( +                                        <Tooltip title={translate("Claim", { name: claim, ns: "consent" })}> +                                            <FormControlLabel +                                                control={<Checkbox id={`claim-${claim}-essential`} disabled checked />} +                                                label={formatClaim( +                                                    translate(`claims.${claim}`, { ns: "consent" }), +                                                    claim, +                                                )} +                                            /> +                                        </Tooltip> +                                    ))} +                                    {response?.claims?.map((claim: string) => ( +                                        <Tooltip title={translate("Claim", { name: claim, ns: "consent" })}> +                                            <FormControlLabel +                                                control={ +                                                    <Checkbox +                                                        id={"claim-" + claim} +                                                        value={claim} +                                                        checked={claimChecked(claim)} +                                                        onChange={handleClaimCheckboxOnChange} +                                                    /> +                                                } +                                                label={formatClaim( +                                                    translate(`claims.${claim}`, { ns: "consent" }), +                                                    claim, +                                                )} +                                            /> +                                        </Tooltip> +                                    ))} +                                </List> +                            </Box> +                        </Grid> +                    ) : null}                      {response?.pre_configuration ? (                          <Grid size={{ xs: 12 }}>                              <Tooltip @@ -236,6 +315,15 @@ const useStyles = makeStyles((theme: Theme) => ({          marginTop: theme.spacing(2),          marginBottom: theme.spacing(2),      }, +    claimsListContainer: { +        textAlign: "center", +    }, +    claimsList: { +        display: "inline-block", +        backgroundColor: theme.palette.background.paper, +        marginTop: theme.spacing(2), +        marginBottom: theme.spacing(2), +    },      clientID: {          fontWeight: "bold",      },  | 
