summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames Elliott <james-d-elliott@users.noreply.github.com>2025-02-22 19:20:34 +1100
committerGitHub <noreply@github.com>2025-02-22 08:20:34 +0000
commit111344eaea4fd0c32ce58a181b94414ae639fe2b (patch)
tree76e024658c1e2483795a8153fe18661ca035c138
parent9c718b39888bbaafdbc623acd0efd2138b6b8068 (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>
-rw-r--r--cmd/authelia-gen/cmd_docs_data.go4
-rw-r--r--cmd/authelia-gen/helpers.go6
-rw-r--r--docs/content/configuration/definitions/_index.md2
-rw-r--r--docs/content/configuration/definitions/introduction.md2
-rw-r--r--docs/content/configuration/definitions/network.md2
-rw-r--r--docs/content/configuration/definitions/user-attributes.md4
-rw-r--r--docs/content/configuration/identity-providers/openid-connect/clients.md8
-rw-r--r--docs/content/configuration/identity-providers/openid-connect/provider.md68
-rw-r--r--docs/content/integration/openid-connect/expressjs/index.md6
-rw-r--r--docs/content/integration/openid-connect/introduction.md156
-rw-r--r--docs/content/integration/openid-connect/nextcloud/index.md2
-rw-r--r--docs/content/integration/openid-connect/openid-connect-1.0-claims.md297
-rw-r--r--docs/data/languages.json4
-rw-r--r--docs/static/schemas/v4.39/json-schema/configuration.json82
-rw-r--r--internal/configuration/provider_test.go54
-rw-r--r--internal/configuration/schema/identity_providers.go29
-rw-r--r--internal/configuration/schema/keys.go18
-rw-r--r--internal/configuration/test_resources/config_oidc_claims.yml276
-rw-r--r--internal/configuration/validator/const.go56
-rw-r--r--internal/configuration/validator/identity_providers.go151
-rw-r--r--internal/configuration/validator/identity_providers_test.go10
-rw-r--r--internal/handlers/handler_oauth_authorization_claims.go69
-rw-r--r--internal/handlers/handler_oidc_authorization.go18
-rw-r--r--internal/handlers/handler_oidc_authorization_consent_implicit.go16
-rw-r--r--internal/handlers/handler_oidc_authorization_consent_pre_configured.go40
-rw-r--r--internal/handlers/handler_oidc_consent.go86
-rw-r--r--internal/handlers/handler_oidc_userinfo.go75
-rw-r--r--internal/handlers/oidc.go161
-rw-r--r--internal/handlers/oidc_test.go277
-rw-r--r--internal/model/oidc.go16
-rw-r--r--internal/oidc/claims.go801
-rw-r--r--internal/oidc/claims_test.go1686
-rw-r--r--internal/oidc/client.go26
-rw-r--r--internal/oidc/client_test.go4
-rw-r--r--internal/oidc/const.go28
-rw-r--r--internal/oidc/discovery.go30
-rw-r--r--internal/oidc/discovery_test.go64
-rw-r--r--internal/oidc/session.go24
-rw-r--r--internal/oidc/types.go41
-rw-r--r--internal/oidc/types_test.go45
-rw-r--r--internal/oidc/util.go55
-rw-r--r--internal/random/cryptographical_test.go2
-rw-r--r--internal/server/locales/en/consent.json37
-rw-r--r--internal/server/locales/en/portal.json1
-rw-r--r--internal/storage/migrations/mysql/V0017.OAuth2Claims.down.sql7
-rw-r--r--internal/storage/migrations/mysql/V0017.OAuth2Claims.up.sql7
-rw-r--r--internal/storage/migrations/postgres/V0017.OAuth2Claims.down.sql7
-rw-r--r--internal/storage/migrations/postgres/V0017.OAuth2Claims.up.sql7
-rw-r--r--internal/storage/migrations/sqlite/V0017.OAuth2Claims.down.sql6
-rw-r--r--internal/storage/migrations/sqlite/V0017.OAuth2Claims.up.sql6
-rw-r--r--internal/storage/migrations_test.go2
-rw-r--r--internal/storage/sql_provider.go10
-rw-r--r--internal/storage/sql_provider_queries.go18
-rw-r--r--internal/suites/example/compose/oidc-client/docker-compose.yml2
-rw-r--r--internal/suites/scenario_oidc_test.go2
-rw-r--r--web/src/i18n/index.ts2
-rw-r--r--web/src/services/ConsentOpenIDConnect.ts56
-rw-r--r--web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx114
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",
},