diff options
61 files changed, 2526 insertions, 528 deletions
diff --git a/config.template.yml b/config.template.yml index 62bf2b15e..d759a9ae5 100644 --- a/config.template.yml +++ b/config.template.yml @@ -1292,46 +1292,6 @@ notifier: # 1oAPwIHNaJJwC4z6oG9E_DO_NOT_USE= # -----END CERTIFICATE----- - ## The issuer_private_key is used to sign the JWT forged by OpenID Connect. This is in addition to the - ## issuer_private_keys option. Assumed to use the RS256 algorithm, and must not be specified if any of the - ## keys in issuer_private_keys also has the algorithm RS256 or are an RSA key without an algorithm. - ## Issuer Private Key can also be set using a secret: https://www.authelia.com/c/secrets - # issuer_private_key: | - # -----BEGIN RSA PRIVATE KEY----- - # MIIBPAIBAAJBAK2i7RlJEYo/Xa6mQmv9zmT0XUj3DcEhRJGPVw2qMyadUFxNg/ZF - # p7aTcToHMf00z6T3b7mwdBkCFQOL3Kb7WRcCAwEAAQJBAJdpB0+RQ9ZFwy9Uk38P - # 5zZpUB8cL8ZFeEFluQeVbt0vyNa+cPLvDLouY87onduXtMz5AKIatLaTOjuG2thh - # SKECIQDY6G8gvsYJdXCE9UJ7ukoLrRHxt/frhAtmSY5lVAPuMwIhAMzuDrJo73LH - # ZyEaqIXc5pIiX3Sag43csPDHfuXdtT2NAiEAhyRKGJzDxiDlefFU+sGWYK/z/iYg - # 0Rvz/kbV8UvnJwECIQDAYN6VJ6NZmc27qv33JIejOfdoTEEhZMMKVg1PlxE0ZQIg - # HFpJiFxZES3QvVPr8deBXORPurqD5uU85NKsf61AdRs_DO_NOT_USE= - # -----END RSA PRIVATE KEY----- - - ## Optional matching certificate chain in PEM DER form that matches the issuer_private_key. All certificates within - ## the chain must be valid and current, and from top to bottom each certificate must be signed by the next - ## certificate in the chain if provided. - # issuer_certificate_chain: | - # -----BEGIN CERTIFICATE----- - # MIIBWzCCAQWgAwIBAgIQYAKsXhJOXKfyySlmpKicTzANBgkqhkiG9w0BAQsFADAT - # MREwDwYDVQQKEwhBdXRoZWxpYTAeFw0yMzA0MjEwMDA3NDRaFw0yNDA0MjAwMDA3 - # NDRaMBMxETAPBgNVBAoTCEF1dGhlbGlhMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJB - # AK2i7RlJEYo/Xa6mQmv9zmT0XUj3DcEhRJGPVw2qMyadUFxNg/ZFp7aTcToHMf00 - # z6T3b7mwdBkCFQOL3Kb7WRcCAwEAAaM1MDMwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud - # JQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADQQB8 - # Of2iM7fPadmtChCMna8lYWH+lEplj6BxOJlRuGRawxszLwi78bnq0sCR33LU6xMx - # 1oAPwIHNaJJwC4z6oG9E_DO_NOT_USE= - # -----END CERTIFICATE----- - # -----BEGIN CERTIFICATE----- - # MIIBWzCCAQWgAwIBAgIQYAKsXhJOXKfyySlmpKicTzANBgkqhkiG9w0BAQsFADAT - # MREwDwYDVQQKEwhBdXRoZWxpYTAeFw0yMzA0MjEwMDA3NDRaFw0yNDA0MjAwMDA3 - # NDRaMBMxETAPBgNVBAoTCEF1dGhlbGlhMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJB - # AK2i7RlJEYo/Xa6mQmv9zmT0XUj3DcEhRJGPVw2qMyadUFxNg/ZFp7aTcToHMf00 - # z6T3b7mwdBkCFQOL3Kb7WRcCAwEAAaM1MDMwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud - # JQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADQQB8 - # Of2iM7fPadmtChCMna8lYWH+lEplj6BxOJlRuGRawxszLwi78bnq0sCR33LU6xMx - # 1oAPwIHNaJJwC4z6oG9E_DO_NOT_USE= - # -----END CERTIFICATE----- - ## Enables additional debug messages. # enable_client_debug_messages: false diff --git a/docs/content/en/configuration/identity-providers/openid-connect/clients.md b/docs/content/en/configuration/identity-providers/openid-connect/clients.md index 76e6ba165..841e5dd13 100644 --- a/docs/content/en/configuration/identity-providers/openid-connect/clients.md +++ b/docs/content/en/configuration/identity-providers/openid-connect/clients.md @@ -211,17 +211,18 @@ This value does not affect the issued ID Tokens as they are always issued with t ### scopes -{{< confkey type="list(string)" default="openid, groups, profile, email" required="no" >}} +{{< confkey type="list(string)" default="openid,groups,profile,email" required="no" >}} A list of scopes to allow this client to consume. See [scope definitions](../../../integration/openid-connect/introduction.md#scope-definitions) for more information. The documentation for the application you are trying to configure [OpenID Connect 1.0] for will likely have a list of scopes or claims required which can be matched with the above guide. -The scope values must be one of those documented in the -[scope definitions](../../../integration/openid-connect/introduction.md#scope-definitions) with the exception of when -the configured [grant_types](#grant_types) includes the `client_credentials` grant in which case arbitrary scopes are -also allowed, +The scope values should generally be one of those documented in the +[scope definitions](../../../integration/openid-connect/introduction.md#scope-definitions) with the exception of when a client requires a specific scope we do not define. Users should +expect to see a warning in the logs if they configure a scope not in our definitions with the exception of a client +where the configured [grant_types](#grant_types) includes the `client_credentials` grant in which case arbitrary scopes are +expected, ### grant_types diff --git a/docs/content/en/configuration/identity-providers/openid-connect/provider.md b/docs/content/en/configuration/identity-providers/openid-connect/provider.md index 197c65504..9a12c8bec 100644 --- a/docs/content/en/configuration/identity-providers/openid-connect/provider.md +++ b/docs/content/en/configuration/identity-providers/openid-connect/provider.md @@ -116,15 +116,54 @@ with 64 or more characters. ### issuer_private_keys -{{< confkey type="list(object" required="no" >}} +{{< confkey type="list(object)" required="yes" >}} -The list of JWKS instead of or in addition to the [issuer_private_key](#issuer_private_key) and -[issuer_certificate_chain](#issuer_certificate_chain). Can also accept ECDSA Private Key's and Certificates. +The list of issuer JSON Web Keys. At least one of these must be an RSA Private key and be configured with the RS256 +algorithm. Can also be used to configure many types of JSON Web Keys for the issuer such as the other RSA based JSON Web +Key formats and ECDSA JSON Web Key formats. -The default key for each algorithm is is decided based on the order of this list. The first key for each algorithm is +The default key for each algorithm is decided based on the order of this list. The first key for each algorithm is considered the default if a client is not configured to use a specific key id. For example if a client has -[id_token_signed_response_alg](clients.md#id_token_signed_response_alg) `ES256` and [id_token_signed_response_key_id](clients.md#id_token_signed_response_key_id) is -not specified then the first `ES256` key in this list is used. +[id_token_signed_response_alg](clients.md#id_token_signed_response_alg) `ES256` and +[id_token_signed_response_key_id](clients.md#id_token_signed_response_key_id) is not specified then the first `ES256` +key in this list is used. + +The following is a contextual example (see below for information regarding each option): + +```yaml +identity_providers: + oidc: + issuer_private_keys: + - key_id: 'example' + algorithm: 'RS256' + use: 'sig' + key: | + -----BEGIN RSA PUBLIC KEY----- + MEgCQQDAwV26ZA1lodtOQxNrJ491gWT+VzFum9IeZ+WTmMypYWyW1CzXKwsvTHDz + 9ec+jserR3EMQ0Rr24lj13FL1ib5AgMBAAE= + -----END RSA PUBLIC KEY---- + certificate_chain: | + -----BEGIN CERTIFICATE----- + MIIBWzCCAQWgAwIBAgIQYAKsXhJOXKfyySlmpKicTzANBgkqhkiG9w0BAQsFADAT + MREwDwYDVQQKEwhBdXRoZWxpYTAeFw0yMzA0MjEwMDA3NDRaFw0yNDA0MjAwMDA3 + NDRaMBMxETAPBgNVBAoTCEF1dGhlbGlhMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJB + AK2i7RlJEYo/Xa6mQmv9zmT0XUj3DcEhRJGPVw2qMyadUFxNg/ZFp7aTcToHMf00 + z6T3b7mwdBkCFQOL3Kb7WRcCAwEAAaM1MDMwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud + JQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADQQB8 + Of2iM7fPadmtChCMna8lYWH+lEplj6BxOJlRuGRawxszLwi78bnq0sCR33LU6xMx + 1oAPwIHNaJJwC4z6oG9E_DO_NOT_USE= + -----END CERTIFICATE----- + -----BEGIN CERTIFICATE----- + MIIBWzCCAQWgAwIBAgIQYAKsXhJOXKfyySlmpKicTzANBgkqhkiG9w0BAQsFADAT + MREwDwYDVQQKEwhBdXRoZWxpYTAeFw0yMzA0MjEwMDA3NDRaFw0yNDA0MjAwMDA3 + NDRaMBMxETAPBgNVBAoTCEF1dGhlbGlhMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJB + AK2i7RlJEYo/Xa6mQmv9zmT0XUj3DcEhRJGPVw2qMyadUFxNg/ZFp7aTcToHMf00 + z6T3b7mwdBkCFQOL3Kb7WRcCAwEAAaM1MDMwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud + JQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADQQB8 + Of2iM7fPadmtChCMna8lYWH+lEplj6BxOJlRuGRawxszLwi78bnq0sCR33LU6xMx + 1oAPwIHNaJJwC4z6oG9E_DO_NOT_USE= + -----END CERTIFICATE----- +``` The following is a contextual example (see below for information regarding each option): @@ -248,48 +287,6 @@ The first certificate in the chain must have the public key for the [key](#key), valid for the current date, and each certificate in the chain should be signed by the certificate immediately following it if present. -### issuer_private_key - -{{< confkey type="string" required="yes" >}} - -The private key used to sign/encrypt the [OpenID Connect 1.0] issued [JWT]'s. The key must be generated by the -administrator and can be done by following the -[Generating an RSA Keypair](../../../reference/guides/generating-secure-values.md#generating-an-rsa-keypair) guide. - -This private key is automatically appended to the [issuer_private_keys](#issuer_private_keys) and assumed to be for the -`RS256` algorithm. If provided it is always the first key in this list. As such this key is assumed to be the default -for `RS256` if provided. - -The issuer private key *__MUST__*: - -* Be a PEM block encoded in the DER base64 format ([RFC4648]). -* Be a RSA private key: - * Encoded in conformance to the [PKCS#8] or [PKCS#1] specifications. - * Have a key size of at least 2048 bits. - -[PKCS#8]: https://datatracker.ietf.org/doc/html/rfc5208 -[PKCS#1]: https://datatracker.ietf.org/doc/html/rfc8017 - -If the [issuer_certificate_chain](#issuer_certificate_chain) is provided the private key must include matching public -key data for the first certificate in the chain. - -### issuer_certificate_chain - -{{< confkey type="string" required="no" >}} - -The certificate chain/bundle to be used with the [issuer_private_key](#issuer_private_key) DER base64 ([RFC4648]) -encoded PEM format used to sign/encrypt the [OpenID Connect 1.0] [JWT]'s. When configured it enables the [x5c] and [x5t] -JSON key's in the JWKs [Discoverable Endpoint](../../../integration/openid-connect/introduction.md#discoverable-endpoints) -as per [RFC7517]. - -[RFC7517]: https://datatracker.ietf.org/doc/html/rfc7517 -[x5c]: https://datatracker.ietf.org/doc/html/rfc7517#section-4.7 -[x5t]: https://datatracker.ietf.org/doc/html/rfc7517#section-4.8 - -The first certificate in the chain must have the public key for the [issuer_private_key](#issuer_private_key), each -certificate in the chain must be valid for the current date, and each certificate in the chain should be signed by the -certificate immediately following it if present. - ### enable_client_debug_messages {{< confkey type="boolean" default="false" required="no" >}} diff --git a/docs/content/en/configuration/methods/secrets.md b/docs/content/en/configuration/methods/secrets.md index eeaa18151..a32826fc3 100644 --- a/docs/content/en/configuration/methods/secrets.md +++ b/docs/content/en/configuration/methods/secrets.md @@ -105,8 +105,6 @@ other configuration using the environment but instead of loading a file the valu [authentication_backend.ldap.password]: ../first-factor/ldap.md#password [authentication_backend.ldap.tls.certificate_chain]: ../first-factor/ldap.md#tls [authentication_backend.ldap.tls.private_key]: ../first-factor/ldap.md#tls -[identity_providers.oidc.issuer_certificate_chain]: ../identity-providers/openid-connect/provider.md#issuer_certificate_chain -[identity_providers.oidc.issuer_private_key]: ../identity-providers/openid-connect/provider.md#issuer_private_key [identity_providers.oidc.hmac_secret]: ../identity-providers/openid-connect/provider.md#hmac_secret [identity_validation.reset_password.jwt_secret]: ../identity-validation/reset-password.md#jwt_secret diff --git a/docs/content/en/configuration/miscellaneous/server-endpoints-authz.md b/docs/content/en/configuration/miscellaneous/server-endpoints-authz.md index daa4864dc..973bb2cd3 100644 --- a/docs/content/en/configuration/miscellaneous/server-endpoints-authz.md +++ b/docs/content/en/configuration/miscellaneous/server-endpoints-authz.md @@ -1,7 +1,7 @@ --- title: "Server Authz Endpoints" description: "Configuring the Server Authz Endpoint Settings." -lead: "Authelia supports several authorization endpoints on the internal webserver. This section describes how to configure and tune them." +lead: "Authelia supports several authorization endpoints on the internal web server. This section describes how to configure and tune them." date: 2023-01-25T20:36:40+11:00 draft: false images: [] @@ -26,16 +26,22 @@ server: implementation: 'ForwardAuth' authn_strategies: - name: 'HeaderProxyAuthorization' + schemes: + - 'Basic' - name: 'CookieSession' ext-authz: implementation: 'ExtAuthz' authn_strategies: - name: 'HeaderProxyAuthorization' + schemes: + - 'Basic' - name: 'CookieSession' auth-request: implementation: 'AuthRequest' authn_strategies: - name: 'HeaderAuthRequestProxyAuthorization' + schemes: + - 'Basic' - name: 'CookieSession' legacy: implementation: 'Legacy' @@ -80,3 +86,11 @@ immediately short-circuit the authentication, otherwise the next strategy in the The name of the strategy. Valid case-sensitive values are `CookieSession`, `HeaderAuthorization`, `HeaderProxyAuthorization`, `HeaderAuthRequestProxyAuthorization`, and `HeaderLegacy`. Read more about the strategies in the [reference guide](../../reference/guides/proxy-authorization.md#authn-strategies). + +#### schemes + +{{< confkey type="list(string)" default="Basic" required="no" >}} + +The list of schemes allowed on this endpoint. Options are `Basic`, and `Bearer`. This option is only applicable to the +`HeaderAuthorization`, `HeaderProxyAuthorization`, and `HeaderAuthRequestProxyAuthorization` strategies and unavailable +with the `legacy` endpoint which only uses `Basic`. diff --git a/docs/content/en/configuration/miscellaneous/server.md b/docs/content/en/configuration/miscellaneous/server.md index 84954d552..0b46d1d00 100644 --- a/docs/content/en/configuration/miscellaneous/server.md +++ b/docs/content/en/configuration/miscellaneous/server.md @@ -1,7 +1,7 @@ --- title: "Server" description: "Configuring the Server Settings." -lead: "Authelia runs an internal webserver. This section describes how to configure and tune this." +lead: "Authelia runs an internal web server. This section describes how to configure and tune this." date: 2022-06-15T17:51:47+10:00 draft: false images: [] @@ -39,19 +39,7 @@ server: endpoints: enable_pprof: false enable_expvars: false - authz: - forward-auth: - implementation: 'ForwardAuth' - authn_strategies: [] - ext-authz: - implementation: 'ExtAuthz' - authn_strategies: [] - auth-request: - implementation: 'AuthRequest' - authn_strategies: [] - legacy: - implementation: 'Legacy' - authn_strategies: [] + authz: {} ## See the dedicated "Server Authz Endpoints" configuration guide. ``` ## Options @@ -83,9 +71,9 @@ server: ### asset_path -{{< confkey type="string " required="no" >}} +{{< confkey type="string" required="no" >}} -Authelia by default serves all static assets from an embedded filesystem in the Go binary. +Authelia by default serves all static assets from an embedded file system in the Go binary. Modifying this setting will allow you to override and serve specific assets for Authelia from a specified path. All assets that can be overridden must be placed in the `asset_path`. The structure of this directory and the assets which @@ -98,7 +86,7 @@ can be overridden is documented in the On startup Authelia checks for the existence of /app/healthcheck.sh and /app/.healthcheck.env and if both of these exist it writes the configuration vars for the healthcheck to the /app/.healthcheck.env file. In instances where this is not -desirable it's possible to disable these interactions entirely. +desirable, it's possible to disable these interactions entirely. An example situation where this is the case is in Kubernetes when set security policies that prevent writing to the ephemeral storage of a container or just don't want to enable the internal health check. @@ -147,8 +135,8 @@ or intermediate certificates. If no item is provided mutual TLS is disabled. {{< confkey type="string" required="no" >}} This customizes the value of the Content-Security-Policy header. It will replace all instances of the below placeholder -with the nonce value of the Authelia react bundle. This is an advanced option to customize and you should do sufficient -research about how browsers utilize and understand this header before attempting to customize it. +with the nonce value of the Authelia react bundle. This is an advanced option to customize, and you should do +sufficient research about how browsers utilize and understand this header before attempting to customize it. {{< csp >}} @@ -195,10 +183,10 @@ Generally this does not need to be configured for most use cases. See the ### Buffer Sizes The read and write buffer sizes generally should be the same. This is because when Authelia verifies -if the user is authorized to visit a URL, it also sends back nearly the same size response as the request. However +if the user is authorized to visit a URL, it also sends back nearly the same size response as the request. However, you're able to tune these individually depending on your needs. ### Asset Overrides -If replacing the Logo for your Authelia portal it is recommended to upload a transparent PNG of your desired logo. +If replacing the Logo for your Authelia portal, it is recommended to upload a transparent PNG of your desired logo. Authelia will automatically resize the logo to an appropriate size to present in the frontend. diff --git a/docs/content/en/configuration/security/access-control.md b/docs/content/en/configuration/security/access-control.md index 6057820e4..6988af4aa 100644 --- a/docs/content/en/configuration/security/access-control.md +++ b/docs/content/en/configuration/security/access-control.md @@ -254,8 +254,14 @@ identify the subject is [one_factor]. See [Rule Matching Concept 2] for more inf This criteria matches identifying characteristics about the subject. Currently this is either user or groups the user belongs to. This allows you to effectively control exactly what each user is authorized to access or to specifically -require two-factor authentication to specific users. Subjects are prefixed with either `user:` or `group:` to identify -which part of the identity to check. +require two-factor authentication to specific users. Subjects must be prefixed with the following prefixes to +specifically match a specific part of a subject. + +| Subject Type | Prefix | Description | +|:----------------:|:----------------:|:----------------------------------------------------------------------------------------------------------------------------------------------:| +| User | `user:` | Matches the username of a user. | +| Group | `group:` | Matches if the user has a group with this name. | +| OAuth 2.0 Client | `oauth2:client:` | Matches if the request has been authorized via a token issued by a client with the specified id utilizing the `client_credentials` grant type. | The format of this rule is unique in as much as it is a list of lists. The logic behind this format is to allow for both `OR` and `AND` logic. The first level of the list defines the `OR` logic, and the second level defines the `AND` logic. diff --git a/docs/content/en/contributing/development/build-and-test.md b/docs/content/en/contributing/development/build-and-test.md index 60db46330..708fac320 100644 --- a/docs/content/en/contributing/development/build-and-test.md +++ b/docs/content/en/contributing/development/build-and-test.md @@ -14,7 +14,7 @@ aliases: - /docs/contributing/build-and-dev.html --- -__Authelia__ is built a [React] frontend user portal bundled in a [Go] application which acts as a basic webserver for +__Authelia__ is built a [React] frontend user portal bundled in a [Go] application which acts as a basic web server for the [React] assets and a dedicated API. The GitHub repository comes with a CLI dedicated to developers called diff --git a/docs/content/en/integration/openid-connect/introduction.md b/docs/content/en/integration/openid-connect/introduction.md index 21c3144df..06f30d35d 100644 --- a/docs/content/en/integration/openid-connect/introduction.md +++ b/docs/content/en/integration/openid-connect/introduction.md @@ -127,6 +127,19 @@ This scope includes the profile information the authentication backend reports a | 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 diff --git a/docs/content/en/integration/openid-connect/oauth-2.0-bearer-token-usage.md b/docs/content/en/integration/openid-connect/oauth-2.0-bearer-token-usage.md new file mode 100644 index 000000000..994fd3da2 --- /dev/null +++ b/docs/content/en/integration/openid-connect/oauth-2.0-bearer-token-usage.md @@ -0,0 +1,281 @@ +--- +title: "OAuth 2.0 Bearer Token Usage" +description: "An introduction into utilizing the Authelia OAuth 2.0 Provider as an authorization method" +lead: "An introduction into utilizing the Authelia OAuth 2.0 Provider as an authorization method." +date: 2024-01-01T13:17:53+11:00 +draft: false +images: [] +menu: + integration: + parent: "openid-connect" +weight: 611 +toc: true +--- + +Access Tokens can be granted which can be leveraged as bearer tokens for the purpose of authorization in place of the +Session Cookie Forwarded Authorization Flow. This is performed leveraging the +[RFC6750: OAuth 2.0 Bearer Token Usage] specification. + +## Authorization Endpoints + +A [registered OAuth 2.0 client](../../configuration/identity-providers/openid-connect/provider.md#clients) which is +permitted to grant the `authelia.bearer.authz` scope is able to request users grant access to a token which can be used +for the forwarded authentication flow integrated into a proxy (i.e. `access_control` rules) in place of the standard +session cookie based authorization flow (which redirects unauthorized users) by utilizing +[RFC6750: OAuth 2.0 Bearer Token Usage] authorization scheme norms (i.e. using the bearer scheme). + +_**Note:** these tokens are not intended for usage with the Authelia API, a separate exclusive scope (or scopes) and +specific audiences will likely be implemented at a later date for this._ + +### General Protections + +The following protections have been taken into account: + +- There are several safeguards to ensure this Authorization Flow cannot operate accidentally. It must be explicitly + configured: + - The authorization endpoint must be explicitly configured to allow the `bearer` scheme. See + [Authorization Endpoint Configuration](#authorization-endpoint-configuration). + - Must be utilizing the new session configuration. See [Session Configuration](#session-configuration). + - The [OpenID Connect 1.0 Provider](../../configuration/identity-providers/openid-connect/provider.md) must be + configured. + - One or more [OpenID Connect 1.0 Clients](../../configuration/identity-providers/openid-connect/clients.md) must be + registered with the `authelia.bearer.authz` scope and relevant required parameters. + - Additional policy requirements are enforced for the client registrations to ensure as much reasonable protection + as possible. +- The token must: + - Be granted the `authelia.bearer.authz` scope. + - Be presented via the `bearer` scheme in the header matching your server Authorization Endpoints configuration. See + [Authorization Endpoint Configuration](#authorization-endpoint-configuration). + - Not be expired, revoked, or otherwise invalid. + - Actually be an Access Token (tokens with the prefix `authelia_at_`, not tokens with the prefixes `authelia_rt_` + or `authelia_ac_`). +- Authorizations using this method have special specific processing rules when considering the access control rules: + - If the token was granted via the `authorization_code` grant then the user who granted the consent for the requested + scope and audience and their effective authentication level (1FA or 2FA) will be used to match the configured + access control rules. + - If the token was granted via the `client_credentials` grant then the token will always be considered as having an + authentication level of 1FA and when it comes to matching a subject rule a special subject type `oauth2:client:<id>` + will match the token instead of a user or groups (where `<id>` is the registered client id). See + [Access Control Configuration](#access-control-configuration). + - The audience of the token is also considered and if the token does not have an audience which is an exact match or + the prefix of the URL being requested the authorization will automatically be denied. +- At this time each request using this scheme will cause a lookup to be performed on the authentication backend. +- Specific changes to the client registration will result in the authorization being denied such as: + - The client is no longer registered. + - The `authelia.bearer.authz` scope is removed from the registration. + - The audience which matches the request is removed from the registration. +- The audience of the token must explicitly be requested. Omission of the `audience` parameter may be denied and will + not grant any audience (thus making it useless) even if the client has been whitelisted for the particular audience. + +For example if `john` consents to grant the token and it includes the audience `https://app1.example.com` but the user +`john` is not normally authorized to visit `https://app1.example.com` the token will not grant access to this resource. +In addition if `john` has his access updated via the access control rules, their groups, etc. then this access is +automatically applied to these tokens. + +These rules effectively give both administrators and end-users fine-grained control over which endpoints can utilize +this authorization scheme as administrators will be required to allow each individual URL prefix which can be requested +and end users will be able to request individual audiences from the allowed list (effectively narrowing the audience +of the token). + +The following recommendations should be considered by users who use this authorization method: + +- Using the JWT Profile for Access Tokens effectively makes the introspection stateless and is discouraged for this + purpose unless you have specific performance issues. We would rather find the cause of the performance issues and + improve them in an instance where they are noticed. + +### Audience Request + +While not explicitly part of the specifications the `audience` parameter can be used during the Authorization Request +phase of the Authorization Code Grant Flow or the Access Token Request phase of the Client Credentials Grant Flow. The +specification leaves it up to Authorization Server policy specifically how audiences are granted and this seems like a +common practice. + +### Authorization Endpoint Configuration + +This authorization scheme is not available by default and must be explicitly enabled. The following examples demonstrate +how to enable this scheme (along with the basic scheme). See the +[Server Authz Endpoints](../../configuration/miscellaneous/server-endpoints-authz.md) configuration guide for more +information. + +```yaml +server: + endpoints: + authz: + forward-auth: + implementation: 'ForwardAuth' + authn_strategies: + - name: 'HeaderAuthorization' + schemes: + - 'Basic' + - 'Bearer' + - name: 'CookieSession' + ext-authz: + implementation: 'ExtAuthz' + authn_strategies: + - name: 'HeaderAuthorization' + schemes: + - 'Basic' + - 'Bearer' + - name: 'CookieSession' + auth-request: + implementation: 'AuthRequest' + authn_strategies: + - name: 'HeaderAuthRequestAuthorization' + schemes: + - 'Basic' + - 'Bearer' + - name: 'CookieSession' + legacy: + implementation: 'Legacy' + authn_strategies: + - name: 'HeaderLegacy' + - name: 'CookieSession' +``` + +### Session Configuration + +This feature is only intended to be supported while using the new session configuration syntax. See the example below. + +```yaml +session: + secret: 'insecure_session_secret' + cookies: + - domain: 'example.com' + authelia_url: 'https://auth.example.com' + default_redirection_url: 'https://www.example.com' +``` + +### Access Control Configuration + +In addition to the restriction of the token audience having to match the target location you must also grant access +in the Access Control section of of the configuration either to the user or in the instance of the `client_credentials` +grant the client itself. + +It is important to note that the `client_credentials` grant is **always** treated as 1FA thus only the `one_factor` +policy is useful for this grant type. + +```yaml +access_control: + rules: + ## The 'app1.example.com' domain for the user 'john' regardless if they're using OAuth 2.0 or session based flows. + - domain: app1.example.com + policy: one_factor + subject: 'user:john' + + ## The 'app2.example.com' domain for the 'example-three' client when using the 'client_credentials' grant. + - domain: app2.example.com + policy: one_factor + subject: 'oauth2:client:example-three' +``` +### Client Restrictions + +In addition to the above protections, this scope **_MUST_** only be configured on clients with strict security rules +which must be explicitly set: + +1. Are not configured with any additional scope with the following exceptions: + - The `offline_access` scope. +2. Have both PAR and PKCE with the `S256` challenge enforced. +3. Have a list of audiences which represent the resources permitted to be allowed by generated tokens. +4. Have the `explicit` consent mode. +5. Only allows the `client_credentials`, or the `authorization_code` and `refresh_token` grant types. +6. Only allows the `code` response type. + - This is not relevant for the `client_credentials` grant type. +7. Only allows the `form_post` or `form_post.jwt` response modes. + - This is not relevant for the `client_credentials` grant type. +8. Must either: + - Be a public client with the Token Endpoint authentication method `none`. See configuration option + `token_endpoint_auth_method`. + - Be a confidential client with the Token Endpoint authentication method `client_secret_post`, `client_secret_jwt`, or + `private_key_jwt` configured. See configuration option `token_endpoint_auth_method`. + +#### Examples + +The following examples illustrate how the [Client Restrictions](#client-restrictions) should be applied to a client. + +##### Public Client Example + +```yaml +identity_providers: + oidc: + clients: + - id: 'example-one' + public: true + redirect_uris: + - 'http://localhost/callback' + scopes: + - 'offline_access' + - 'authelia.bearer.authz' + audience: + - 'https://app1.example.com' + - 'https://app2.example.com' + grant_types: + - 'authorization_code' + - 'refresh_token' + response_types: + - 'code' + response_modes: + - 'form_post' + consent_mode: 'explicit' + enforce_par: true + enforce_pkce: true + pkce_challenge_method: 'S256' + token_endpoint_auth_method: 'none' +``` + +##### Confidential Client Example: Authorization Code Flow + +This is likely the most common configuration for most users. + +```yaml +identity_providers: + oidc: + clients: + - id: 'example-two' + secret: '$pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng' # The digest of 'insecure_secret'. + public: false + redirect_uris: + - 'http://localhost/callback' + scopes: + - 'offline_access' + - 'authelia.bearer.authz' + audience: + - 'https://app1.example.com' + - 'https://app2.example.com' + grant_types: + - 'authorization_code' + - 'refresh_token' + response_types: + - 'code' + response_modes: + - 'form_post' + consent_mode: 'explicit' + enforce_par: true + enforce_pkce: true + pkce_challenge_method: 'S256' + token_endpoint_auth_method: 'client_secret_post' +``` + +##### Confidential Client Example: Client Credentials Flow + +This example illustrates a method to configure a Client Credential flow for this purpose. This flow is useful for +automations. It's important to note that for access control evaluation purposes this token will match a subject of +`oauth2:client:example-two` i.e. the `oauth2:client:` prefix followed by the client id. + +```yaml +identity_providers: + oidc: + clients: + - id: 'example-three' + secret: '$pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng' # The digest of 'insecure_secret'. + public: false + scopes: + - 'authelia.bearer.authz' + audience: + - 'https://app1.example.com' + - 'https://app2.example.com' + grant_types: + - 'client_credentials' + token_endpoint_auth_method: 'client_secret_post' +``` + +[RFC6750: OAuth 2.0 Bearer Token Usage]: https://datatracker.ietf.org/doc/html/rfc6750 diff --git a/docs/content/en/integration/openid-connect/tailscale/index.md b/docs/content/en/integration/openid-connect/tailscale/index.md index e29d9a3fd..547e91258 100644 --- a/docs/content/en/integration/openid-connect/tailscale/index.md +++ b/docs/content/en/integration/openid-connect/tailscale/index.md @@ -64,7 +64,7 @@ identity_providers: To configure [Tailscale] to utilize Authelia as a [OpenID Connect 1.0] Provider, you will need a public WebFinger reply for your domain (see [RFC7033 Section 3.1]) and point it to Authelia. The steps necessary are outlined in the Tailscale documentation on [Custom OIDC providers KB article]. This WebFinger reply is not generated by Authelia, so your external -webserver hosted at the root of your domain will need to generate the response (Check [See also](#see-also) for example +web server hosted at the root of your domain will need to generate the response (Check [See also](#see-also) for example implementations). The following steps are necessary to get Tailscale working with Authelia: 1. Your domain will need to reply to a WebFinger request for your Authelia account diff --git a/docs/content/en/reference/guides/templating.md b/docs/content/en/reference/guides/templating.md index f4069b1b7..52efd5452 100644 --- a/docs/content/en/reference/guides/templating.md +++ b/docs/content/en/reference/guides/templating.md @@ -99,6 +99,10 @@ The following is a list of special functions and their syntax. This template function takes a single input and is a positive integer. Returns a slice of uints from 0 to the provided input. +#### mustEnv + +Same as [env](#env) except if the environment variable is not set it returns an error. + #### fileContent This template function takes a single input and is a string which should be a path. Returns the content of a file. diff --git a/docs/data/configkeys.json b/docs/data/configkeys.json index 5d1760c85..14e99122e 100644 --- a/docs/data/configkeys.json +++ b/docs/data/configkeys.json @@ -40,16 +40,6 @@ "env": "AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE" }, { - "path": "identity_providers.oidc.issuer_certificate_chain", - "secret": true, - "env": "AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_CERTIFICATE_CHAIN_FILE" - }, - { - "path": "identity_providers.oidc.issuer_private_key", - "secret": true, - "env": "AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE" - }, - { "path": "identity_providers.oidc.enable_client_debug_messages", "secret": false, "env": "AUTHELIA_IDENTITY_PROVIDERS_OIDC_ENABLE_CLIENT_DEBUG_MESSAGES" diff --git a/docs/static/schemas/latest/json-schema/configuration.json b/docs/static/schemas/latest/json-schema/configuration.json index ad7605a62..598640194 100644 --- a/docs/static/schemas/latest/json-schema/configuration.json +++ b/docs/static/schemas/latest/json-schema/configuration.json @@ -279,12 +279,12 @@ "oneOf": [ { "type": "string", - "pattern": "^(user|group):.+$" + "pattern": "^(user|group|oauth2:client:):.+$" }, { "items": { "type": "string", - "pattern": "^(user|group):.+$" + "pattern": "^(user|group|oauth2:client:):.+$" }, "type": "array" }, @@ -292,7 +292,7 @@ "items": { "items": { "type": "string", - "pattern": "^(user|group):.+$" + "pattern": "^(user|group|oauth2:client:):.+$" }, "type": "array" }, @@ -1029,12 +1029,14 @@ "issuer_certificate_chain": { "$ref": "#/$defs/X509CertificateChain", "title": "Issuer Certificate Chain", - "description": "The Issuer Certificate Chain with an RSA Public Key used to sign ID Tokens." + "description": "The Issuer Certificate Chain with an RSA Public Key used to sign ID Tokens.", + "deprecated": true }, "issuer_private_key": { "type": "string", "title": "Issuer Private Key", - "description": "The Issuer Private Key with an RSA Private Key used to sign ID Tokens." + "description": "The Issuer Private Key with an RSA Private Key used to sign ID Tokens.", + "deprecated": true }, "enable_client_debug_messages": { "type": "boolean", @@ -2575,6 +2577,21 @@ ], "title": "Name", "description": "The name of the Authorization strategy to use." + }, + "schemes": { + "items": { + "type": "string", + "enum": [ + "basic", + "bearer" + ] + }, + "type": "array", + "title": "Authorization Schemes", + "description": "The name of the authorization schemes to allow with the header strategies.", + "default": [ + "basic" + ] } }, "additionalProperties": false, diff --git a/docs/static/schemas/v4.38/json-schema/configuration.json b/docs/static/schemas/v4.38/json-schema/configuration.json index ad7605a62..598640194 100644 --- a/docs/static/schemas/v4.38/json-schema/configuration.json +++ b/docs/static/schemas/v4.38/json-schema/configuration.json @@ -279,12 +279,12 @@ "oneOf": [ { "type": "string", - "pattern": "^(user|group):.+$" + "pattern": "^(user|group|oauth2:client:):.+$" }, { "items": { "type": "string", - "pattern": "^(user|group):.+$" + "pattern": "^(user|group|oauth2:client:):.+$" }, "type": "array" }, @@ -292,7 +292,7 @@ "items": { "items": { "type": "string", - "pattern": "^(user|group):.+$" + "pattern": "^(user|group|oauth2:client:):.+$" }, "type": "array" }, @@ -1029,12 +1029,14 @@ "issuer_certificate_chain": { "$ref": "#/$defs/X509CertificateChain", "title": "Issuer Certificate Chain", - "description": "The Issuer Certificate Chain with an RSA Public Key used to sign ID Tokens." + "description": "The Issuer Certificate Chain with an RSA Public Key used to sign ID Tokens.", + "deprecated": true }, "issuer_private_key": { "type": "string", "title": "Issuer Private Key", - "description": "The Issuer Private Key with an RSA Private Key used to sign ID Tokens." + "description": "The Issuer Private Key with an RSA Private Key used to sign ID Tokens.", + "deprecated": true }, "enable_client_debug_messages": { "type": "boolean", @@ -2575,6 +2577,21 @@ ], "title": "Name", "description": "The name of the Authorization strategy to use." + }, + "schemes": { + "items": { + "type": "string", + "enum": [ + "basic", + "bearer" + ] + }, + "type": "array", + "title": "Authorization Schemes", + "description": "The name of the authorization schemes to allow with the header strategies.", + "default": [ + "basic" + ] } }, "additionalProperties": false, diff --git a/internal/authorization/access_control_subjects.go b/internal/authorization/access_control_subjects.go index 4159f0d24..a77c8add2 100644 --- a/internal/authorization/access_control_subjects.go +++ b/internal/authorization/access_control_subjects.go @@ -48,3 +48,14 @@ type AccessControlGroup struct { func (acg AccessControlGroup) IsMatch(subject Subject) (match bool) { return utils.IsStringInSlice(acg.Name, subject.Groups) } + +// AccessControlClient represents an ACL subject of type `oauth2:client:`. +type AccessControlClient struct { + Provider string + ID string +} + +// IsMatch returns true if the AccessControlClient name matches one of the groups of the Subject. +func (acg AccessControlClient) IsMatch(subject Subject) (match bool) { + return acg.ID == subject.ClientID +} diff --git a/internal/authorization/authorizer_test.go b/internal/authorization/authorizer_test.go index dbfc5e98b..0548181ad 100644 --- a/internal/authorization/authorizer_test.go +++ b/internal/authorization/authorizer_test.go @@ -78,6 +78,11 @@ var AnonymousUser = Subject{ IP: net.ParseIP("127.0.0.1"), } +var OAuth2UserClientAClient = Subject{ + ClientID: "a_client", + IP: net.ParseIP("127.0.0.1"), +} + var UserWithGroups = Subject{ Username: "john", Groups: []string{"dev", "admins"}, @@ -336,13 +341,18 @@ func (s *AuthorizerSuite) TestShouldCheckRulePrecedence() { tester := NewAuthorizerBuilder(). WithDefaultPolicy(deny). WithRule(schema.AccessControlRule{ + Domains: []string{"public.example.com"}, + Policy: bypass, + }). + WithRule(schema.AccessControlRule{ Domains: []string{"protected.example.com"}, - Policy: bypass, + Policy: oneFactor, Subjects: [][]string{{"user:john"}}, }). WithRule(schema.AccessControlRule{ - Domains: []string{"protected.example.com"}, - Policy: oneFactor, + Domains: []string{"protected.example.com"}, + Policy: oneFactor, + Subjects: [][]string{{"oauth2:client:a_client"}}, }). WithRule(schema.AccessControlRule{ Domains: []string{"*.example.com"}, @@ -350,9 +360,13 @@ func (s *AuthorizerSuite) TestShouldCheckRulePrecedence() { }). Build() - tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", fasthttp.MethodGet, Bypass) - tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", fasthttp.MethodGet, OneFactor) - tester.CheckAuthorizations(s.T(), John, "https://public.example.com/", fasthttp.MethodGet, TwoFactor) + tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", fasthttp.MethodGet, OneFactor) + tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", fasthttp.MethodGet, TwoFactor) + tester.CheckAuthorizations(s.T(), John, "https://public.example.com/", fasthttp.MethodGet, Bypass) + tester.CheckAuthorizations(s.T(), Bob, "https://public.example.com/", fasthttp.MethodGet, Bypass) + tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example.com/", fasthttp.MethodGet, Bypass) + tester.CheckAuthorizations(s.T(), OAuth2UserClientAClient, "https://public.example.com/", fasthttp.MethodGet, Bypass) + tester.CheckAuthorizations(s.T(), OAuth2UserClientAClient, "https://protected.example.com/", fasthttp.MethodGet, OneFactor) } func (s *AuthorizerSuite) TestShouldCheckDomainMatching() { diff --git a/internal/authorization/const.go b/internal/authorization/const.go index d20fa20f3..e4463b2d9 100644 --- a/internal/authorization/const.go +++ b/internal/authorization/const.go @@ -18,8 +18,15 @@ const ( ) const ( - prefixUser = "user:" - prefixGroup = "group:" + prefixUser = "user:" + prefixGroup = "group:" + prefixOAuth2Client = "oauth2:client:" +) + +const ( + lenPrefixUser = len(prefixUser) + lenPrefixGroup = len(prefixGroup) + lenPrefixOAuth2Client = len(prefixOAuth2Client) ) const ( diff --git a/internal/authorization/types.go b/internal/authorization/types.go index 4e26a2db3..2ed982132 100644 --- a/internal/authorization/types.go +++ b/internal/authorization/types.go @@ -33,6 +33,7 @@ type ObjectMatcher interface { type Subject struct { Username string Groups []string + ClientID string IP net.IP } diff --git a/internal/authorization/util.go b/internal/authorization/util.go index 04ce482e4..e8b5990ae 100644 --- a/internal/authorization/util.go +++ b/internal/authorization/util.go @@ -42,9 +42,10 @@ func (l Level) String() string { } func stringSliceToRegexpSlice(strings []string) (regexps []regexp.Regexp, err error) { + var pattern *regexp.Regexp + for _, str := range strings { - pattern, err := regexp.Compile(str) - if err != nil { + if pattern, err = regexp.Compile(str); err != nil { return nil, err } @@ -56,17 +57,23 @@ func stringSliceToRegexpSlice(strings []string) (regexps []regexp.Regexp, err er func schemaSubjectToACLSubject(subjectRule string) (subject SubjectMatcher) { if strings.HasPrefix(subjectRule, prefixUser) { - user := strings.Trim(subjectRule[len(prefixUser):], " ") + user := strings.Trim(subjectRule[lenPrefixUser:], " ") return AccessControlUser{Name: user} } if strings.HasPrefix(subjectRule, prefixGroup) { - group := strings.Trim(subjectRule[len(prefixGroup):], " ") + group := strings.Trim(subjectRule[lenPrefixGroup:], " ") return AccessControlGroup{Name: group} } + if strings.HasPrefix(subjectRule, prefixOAuth2Client) { + clientID := strings.Trim(subjectRule[lenPrefixOAuth2Client:], " ") + + return AccessControlClient{Provider: "OAuth2", ID: clientID} + } + return nil } diff --git a/internal/commands/services.go b/internal/commands/services.go index 2102cd105..653e0dc9b 100644 --- a/internal/commands/services.go +++ b/internal/commands/services.go @@ -106,7 +106,7 @@ type Service interface { Log() *logrus.Entry } -// ServerService is a Service which runs a webserver. +// ServerService is a Service which runs a web server. type ServerService struct { name string server *fasthttp.Server diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 62bf2b15e..d759a9ae5 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -1292,46 +1292,6 @@ notifier: # 1oAPwIHNaJJwC4z6oG9E_DO_NOT_USE= # -----END CERTIFICATE----- - ## The issuer_private_key is used to sign the JWT forged by OpenID Connect. This is in addition to the - ## issuer_private_keys option. Assumed to use the RS256 algorithm, and must not be specified if any of the - ## keys in issuer_private_keys also has the algorithm RS256 or are an RSA key without an algorithm. - ## Issuer Private Key can also be set using a secret: https://www.authelia.com/c/secrets - # issuer_private_key: | - # -----BEGIN RSA PRIVATE KEY----- - # MIIBPAIBAAJBAK2i7RlJEYo/Xa6mQmv9zmT0XUj3DcEhRJGPVw2qMyadUFxNg/ZF - # p7aTcToHMf00z6T3b7mwdBkCFQOL3Kb7WRcCAwEAAQJBAJdpB0+RQ9ZFwy9Uk38P - # 5zZpUB8cL8ZFeEFluQeVbt0vyNa+cPLvDLouY87onduXtMz5AKIatLaTOjuG2thh - # SKECIQDY6G8gvsYJdXCE9UJ7ukoLrRHxt/frhAtmSY5lVAPuMwIhAMzuDrJo73LH - # ZyEaqIXc5pIiX3Sag43csPDHfuXdtT2NAiEAhyRKGJzDxiDlefFU+sGWYK/z/iYg - # 0Rvz/kbV8UvnJwECIQDAYN6VJ6NZmc27qv33JIejOfdoTEEhZMMKVg1PlxE0ZQIg - # HFpJiFxZES3QvVPr8deBXORPurqD5uU85NKsf61AdRs_DO_NOT_USE= - # -----END RSA PRIVATE KEY----- - - ## Optional matching certificate chain in PEM DER form that matches the issuer_private_key. All certificates within - ## the chain must be valid and current, and from top to bottom each certificate must be signed by the next - ## certificate in the chain if provided. - # issuer_certificate_chain: | - # -----BEGIN CERTIFICATE----- - # MIIBWzCCAQWgAwIBAgIQYAKsXhJOXKfyySlmpKicTzANBgkqhkiG9w0BAQsFADAT - # MREwDwYDVQQKEwhBdXRoZWxpYTAeFw0yMzA0MjEwMDA3NDRaFw0yNDA0MjAwMDA3 - # NDRaMBMxETAPBgNVBAoTCEF1dGhlbGlhMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJB - # AK2i7RlJEYo/Xa6mQmv9zmT0XUj3DcEhRJGPVw2qMyadUFxNg/ZFp7aTcToHMf00 - # z6T3b7mwdBkCFQOL3Kb7WRcCAwEAAaM1MDMwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud - # JQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADQQB8 - # Of2iM7fPadmtChCMna8lYWH+lEplj6BxOJlRuGRawxszLwi78bnq0sCR33LU6xMx - # 1oAPwIHNaJJwC4z6oG9E_DO_NOT_USE= - # -----END CERTIFICATE----- - # -----BEGIN CERTIFICATE----- - # MIIBWzCCAQWgAwIBAgIQYAKsXhJOXKfyySlmpKicTzANBgkqhkiG9w0BAQsFADAT - # MREwDwYDVQQKEwhBdXRoZWxpYTAeFw0yMzA0MjEwMDA3NDRaFw0yNDA0MjAwMDA3 - # NDRaMBMxETAPBgNVBAoTCEF1dGhlbGlhMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJB - # AK2i7RlJEYo/Xa6mQmv9zmT0XUj3DcEhRJGPVw2qMyadUFxNg/ZFp7aTcToHMf00 - # z6T3b7mwdBkCFQOL3Kb7WRcCAwEAAaM1MDMwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud - # JQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADQQB8 - # Of2iM7fPadmtChCMna8lYWH+lEplj6BxOJlRuGRawxszLwi78bnq0sCR33LU6xMx - # 1oAPwIHNaJJwC4z6oG9E_DO_NOT_USE= - # -----END CERTIFICATE----- - ## Enables additional debug messages. # enable_client_debug_messages: false diff --git a/internal/configuration/deprecation.go b/internal/configuration/deprecation.go index 420acd194..ad48456ba 100644 --- a/internal/configuration/deprecation.go +++ b/internal/configuration/deprecation.go @@ -358,6 +358,26 @@ var deprecations = map[string]Deprecation{ MapFunc: nil, ErrFunc: nil, }, + "identity_providers.oidc.issuer_private_key": { + Version: model.SemanticVersion{Major: 4, Minor: 38}, + Key: "identity_providers.oidc.issuer_private_key", + NewKey: "identity_providers.oidc.issuer_private_keys", + AutoMap: false, + MapFunc: nil, + ErrFunc: func(d Deprecation, keysFinal map[string]any, value any, val *schema.StructValidator) { + val.PushWarning(fmt.Errorf("configuration key '%s' is deprecated in %s and should be configured using the new configuration key '%s': this has been automatically mapped for you but you will need to adjust your configuration (see https://www.authelia.com/c/oidc) to remove this message", d.Key, d.Version, d.NewKey)) + }, + }, + "identity_providers.oidc.issuer_certificate_chain": { + Version: model.SemanticVersion{Major: 4, Minor: 38}, + Key: "identity_providers.oidc.issuer_certificate_chain", + NewKey: "identity_providers.oidc.issuer_private_keys", + AutoMap: false, + MapFunc: nil, + ErrFunc: func(d Deprecation, keysFinal map[string]any, value any, val *schema.StructValidator) { + val.PushWarning(fmt.Errorf("configuration key '%s' is deprecated in %s and should be configured using the new configuration key '%s': this has been automatically mapped for you but you will need to adjust your configuration (see https://www.authelia.com/c/oidc) to remove this message", d.Key, d.Version, d.NewKey)) + }, + }, "jwt_secret": { Version: model.SemanticVersion{Major: 4, Minor: 38}, Key: "jwt_secret", diff --git a/internal/configuration/helpers_test.go b/internal/configuration/helpers_test.go index fcb7e343d..678b8535c 100644 --- a/internal/configuration/helpers_test.go +++ b/internal/configuration/helpers_test.go @@ -48,11 +48,12 @@ func TestGetEnvConfigMaps(t *testing.T) { assert.True(t, ok) assert.Equal(t, "mysecret.password", key) - assert.Len(t, ignoredKeys, 4) - assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"MYOTHER_CONFIGKEY_FILE") - assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"MYSECRET_PASSWORD_FILE") - assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"MYSECRET_USER_PASSWORD_FILE") - assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"JWT_SECRET_FILE") + assert.Len(t, ignoredKeys, 6) + assert.Contains(t, ignoredKeys, DefaultEnvPrefix+MYOTHER_CONFIGKEY_FILE) + assert.Contains(t, ignoredKeys, DefaultEnvPrefix+MYSECRET_PASSWORD_FILE) + assert.Contains(t, ignoredKeys, DefaultEnvPrefix+MYSECRET_USER_PASSWORD_FILE) + assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE") + assert.Contains(t, ignoredKeys, DefaultEnvPrefix+"IDENTITY_PROVIDERS_OIDC_ISSUER_CERTIFICATE_CHAIN_FILE") } func TestGetSecretConfigMapMockInput(t *testing.T) { diff --git a/internal/configuration/schema/const.go b/internal/configuration/schema/const.go index 52c92d591..d5de64444 100644 --- a/internal/configuration/schema/const.go +++ b/internal/configuration/schema/const.go @@ -125,8 +125,32 @@ const ( ) const ( - blockCERTIFICATE = "CERTIFICATE" - blockRSAPRIVATEKEY = "RSA PRIVATE KEY" + blockCERTIFICATE = "CERTIFICATE" +) + +// Authorization Schemes. +const ( + SchemeBasic = "basic" + SchemeBearer = "bearer" +) + +// Authz values. +const ( + AuthzEndpointNameLegacy = "legacy" + AuthzEndpointNameAuthRequest = "auth-request" + AuthzEndpointNameExtAuthz = "ext-authz" + AuthzEndpointNameForwardAuth = "forward-auth" + + AuthzImplementationLegacy = "Legacy" + AuthzImplementationAuthRequest = "AuthRequest" + AuthzImplementationExtAuthz = "ExtAuthz" + AuthzImplementationForwardAuth = "ForwardAuth" + + AuthzStrategyHeaderCookieSession = "CookieSession" + AuthzStrategyHeaderAuthorization = "HeaderAuthorization" + AuthzStrategyHeaderProxyAuthorization = "HeaderProxyAuthorization" + AuthzStrategyHeaderAuthRequestProxyAuthorization = "HeaderAuthRequestProxyAuthorization" + AuthzStrategyHeaderLegacy = "HeaderLegacy" ) const ( diff --git a/internal/configuration/schema/identity_providers.go b/internal/configuration/schema/identity_providers.go index e755ba45b..2d1f6fcad 100644 --- a/internal/configuration/schema/identity_providers.go +++ b/internal/configuration/schema/identity_providers.go @@ -16,8 +16,8 @@ type IdentityProvidersOpenIDConnect struct { HMACSecret string `koanf:"hmac_secret" json:"hmac_secret" jsonschema:"title=HMAC Secret" jsonschema_description:"The HMAC Secret used to sign Access Tokens."` IssuerPrivateKeys []JWK `koanf:"issuer_private_keys" json:"issuer_private_keys" jsonschema:"title=Issuer Private Keys" jsonschema_description:"The Private Keys used to sign ID Tokens."` - IssuerCertificateChain X509CertificateChain `koanf:"issuer_certificate_chain" json:"issuer_certificate_chain" jsonschema:"title=Issuer Certificate Chain" jsonschema_description:"The Issuer Certificate Chain with an RSA Public Key used to sign ID Tokens."` - IssuerPrivateKey *rsa.PrivateKey `koanf:"issuer_private_key" json:"issuer_private_key" jsonschema:"title=Issuer Private Key" jsonschema_description:"The Issuer Private Key with an RSA Private Key used to sign ID Tokens."` + IssuerCertificateChain X509CertificateChain `koanf:"issuer_certificate_chain" json:"issuer_certificate_chain" jsonschema:"title=Issuer Certificate Chain,deprecated" jsonschema_description:"The Issuer Certificate Chain with an RSA Public Key used to sign ID Tokens."` + 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."` EnableClientDebugMessages bool `koanf:"enable_client_debug_messages" json:"enable_client_debug_messages" jsonschema:"default=false,title=Enable Client Debug Messages" jsonschema_description:"Enables additional debug messages for clients."` MinimumParameterEntropy int `koanf:"minimum_parameter_entropy" json:"minimum_parameter_entropy" jsonschema:"default=8,minimum=-1,title=Minimum Parameter Entropy" jsonschema_description:"The minimum entropy of the nonce parameter."` @@ -61,6 +61,7 @@ type IdentityProvidersOpenIDConnectDiscovery struct { ResponseObjectSigningAlgs []string RequestObjectSigningAlgs []string JWTResponseAccessTokens bool + BearerAuthorization bool } type IdentityProvidersOpenIDConnectLifespans struct { diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index de96acdb3..52a5e5a4a 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -318,6 +318,7 @@ var Keys = []string{ "server.endpoints.authz.*.implementation", "server.endpoints.authz.*.authn_strategies", "server.endpoints.authz.*.authn_strategies[].name", + "server.endpoints.authz.*.authn_strategies[].schemes", "server.buffers.read", "server.buffers.write", "server.timeouts.read", diff --git a/internal/configuration/schema/server.go b/internal/configuration/schema/server.go index bc9069c43..2a5e4f299 100644 --- a/internal/configuration/schema/server.go +++ b/internal/configuration/schema/server.go @@ -45,7 +45,8 @@ type ServerEndpointsAuthz struct { // ServerEndpointsAuthzAuthnStrategy is the Authz endpoints configuration for the HTTP server. type ServerEndpointsAuthzAuthnStrategy struct { - Name string `koanf:"name" json:"name" jsonschema:"enum=HeaderAuthorization,enum=HeaderProxyAuthorization,enum=HeaderAuthRequestProxyAuthorization,enum=HeaderLegacy,enum=CookieSession,title=Name" jsonschema_description:"The name of the Authorization strategy to use."` + Name string `koanf:"name" json:"name" jsonschema:"enum=HeaderAuthorization,enum=HeaderProxyAuthorization,enum=HeaderAuthRequestProxyAuthorization,enum=HeaderLegacy,enum=CookieSession,title=Name" jsonschema_description:"The name of the Authorization strategy to use."` + Schemes []string `koanf:"schemes" json:"schemes" jsonschema:"enum=basic,enum=bearer,default=basic,title=Authorization Schemes" jsonschema_description:"The name of the authorization schemes to allow with the header strategies."` } // ServerTLS represents the configuration of the http servers TLS options. @@ -74,39 +75,50 @@ var DefaultServerConfiguration = Server{ }, Endpoints: ServerEndpoints{ Authz: map[string]ServerEndpointsAuthz{ - "legacy": { - Implementation: "Legacy", + AuthzEndpointNameLegacy: { + Implementation: AuthzImplementationLegacy, + AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{ + { + Name: AuthzStrategyHeaderLegacy, + }, + { + Name: AuthzStrategyHeaderCookieSession, + }, + }, }, - "auth-request": { - Implementation: "AuthRequest", + AuthzEndpointNameAuthRequest: { + Implementation: AuthzImplementationAuthRequest, AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{ { - Name: "HeaderAuthRequestProxyAuthorization", + Name: AuthzStrategyHeaderAuthorization, + Schemes: []string{SchemeBasic}, }, { - Name: "CookieSession", + Name: AuthzStrategyHeaderCookieSession, }, }, }, - "forward-auth": { - Implementation: "ForwardAuth", + AuthzEndpointNameExtAuthz: { + Implementation: AuthzImplementationExtAuthz, AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{ { - Name: "HeaderProxyAuthorization", + Name: AuthzStrategyHeaderAuthorization, + Schemes: []string{SchemeBasic}, }, { - Name: "CookieSession", + Name: AuthzStrategyHeaderCookieSession, }, }, }, - "ext-authz": { - Implementation: "ExtAuthz", + AuthzEndpointNameForwardAuth: { + Implementation: AuthzImplementationForwardAuth, AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{ { - Name: "HeaderProxyAuthorization", + Name: AuthzStrategyHeaderAuthorization, + Schemes: []string{SchemeBasic}, }, { - Name: "CookieSession", + Name: AuthzStrategyHeaderCookieSession, }, }, }, diff --git a/internal/configuration/schema/types.go b/internal/configuration/schema/types.go index 7f0497c51..a7705db1f 100644 --- a/internal/configuration/schema/types.go +++ b/internal/configuration/schema/types.go @@ -604,7 +604,7 @@ var jsonschemaACLNetwork = jsonschema.Schema{ var jsonschemaACLSubject = jsonschema.Schema{ Type: jsonschema.TypeString, - Pattern: "^(user|group):.+$", + Pattern: "^(user|group|oauth2:client:):.+$", } var jsonschemaACLMethod = jsonschema.Schema{ diff --git a/internal/configuration/validator/access_control.go b/internal/configuration/validator/access_control.go index 9b92eff80..9b7db2bea 100644 --- a/internal/configuration/validator/access_control.go +++ b/internal/configuration/validator/access_control.go @@ -18,7 +18,7 @@ func IsPolicyValid(policy string) (isValid bool) { // IsSubjectValid check if a subject is valid. func IsSubjectValid(subject string) (isValid bool) { - return subject == "" || strings.HasPrefix(subject, "user:") || strings.HasPrefix(subject, "group:") + return subject == "" || strings.HasPrefix(subject, "user:") || strings.HasPrefix(subject, "group:") || strings.HasPrefix(subject, "oauth2:client:") } // IsNetworkGroupValid check if a network group is valid. diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index a2c636ece..d212ab9ce 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -159,7 +159,7 @@ const ( const ( errFmtOIDCProviderNoClientsConfigured = "identity_providers: oidc: option 'clients' must have one or " + "more clients configured" - errFmtOIDCProviderNoPrivateKey = "identity_providers: oidc: option `issuer_private_keys` or 'issuer_private_key' is required" + errFmtOIDCProviderNoPrivateKey = "identity_providers: oidc: option `issuer_private_keys` is required" errFmtOIDCProviderEnforcePKCEInvalidValue = "identity_providers: oidc: option 'enforce_pkce' must be 'never', " + "'public_clients_only' or 'always', but it's configured as '%s'" errFmtOIDCProviderInsecureParameterEntropy = "identity_providers: oidc: option 'minimum_parameter_entropy' is " @@ -196,6 +196,10 @@ const ( errFmtOIDCClientsWithEmptyID = "identity_providers: oidc: clients: option 'id' is required but was absent on the clients in positions %s" errFmtOIDCClientsDeprecated = "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" + errFmtMustOnlyHaveValues = "'%s' must only have the values %s " + errFmtMustBeConfiguredAs = "'%s' must be configured as %s " + errFmtOIDCClientOption = "identity_providers: oidc: clients: client '%s': option " + errFmtOIDCWhenScope = "when configured with scope '%s'" errFmtOIDCClientInvalidSecretIs = errFmtOIDCClientOption + "'secret' is " errFmtOIDCClientInvalidSecret = errFmtOIDCClientInvalidSecretIs + "required" errFmtOIDCClientInvalidSecretPlainText = errFmtOIDCClientInvalidSecretIs + "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 when oidc becomes stable" @@ -204,7 +208,9 @@ const ( "required to be empty when option 'public' is true" errFmtOIDCClientPublicInvalidSecretClientAuthMethod = errFmtOIDCClientInvalidSecretIs + "required to be empty when option 'token_endpoint_auth_method' is configured as '%s'" - errFmtOIDCClientOption = "identity_providers: oidc: clients: client '%s': option " + errFmtOIDCClientIDTooLong = errFmtOIDCClientOption + "'id' must not be more than 100 characters but it has %d characters" + errFmtOIDCClientIDInvalidCharacters = errFmtOIDCClientOption + "'id' must only contain RFC3986 unreserved characters" + errFmtOIDCClientRedirectURIHas = errFmtOIDCClientOption + "'redirect_uris' has " errFmtOIDCClientRedirectURICantBeParsed = errFmtOIDCClientRedirectURIHas + "an invalid value: redirect uri '%s' could not be parsed: %v" @@ -215,10 +221,19 @@ const ( "an invalid value: redirect uri '%s' must have a scheme but it's absent" errFmtOIDCClientInvalidConsentMode = "identity_providers: oidc: clients: client '%s': consent: option 'mode' must be one of " + "%s but it's configured as '%s'" - errFmtOIDCClientInvalidEntries = errFmtOIDCClientOption + "'%s' must only have the values " + - "%s but the values %s are present" + errFmtOIDCClientInvalidEntries = errFmtOIDCClientOption + errFmtMustOnlyHaveValues + + "but the values %s are present" + errFmtOIDCClientUnknownScopeEntries = errFmtOIDCClientOption + "'%s' only expects the values " + + "%s but the unknown values %s are present and should generally only be used if a particular client requires a scope outside of our standard scopes" + errFmtOIDCClientInvalidEntriesScope = errFmtOIDCClientOption + errFmtMustOnlyHaveValues + + errFmtOIDCWhenScope + " but the values %s are present" + errFmtOIDCClientEmptyEntriesScope = errFmtOIDCClientOption + errFmtMustOnlyHaveValues + + errFmtOIDCWhenScope + " but it's not configured" + errFmtOIDCClientOptionRequiredScope = errFmtOIDCClientOption + "'%s' must be configured " + errFmtOIDCWhenScope + " but it's absent" + errFmtOIDCClientOptionMustScope = errFmtOIDCClientOption + errFmtMustBeConfiguredAs + errFmtOIDCWhenScope + " but it's configured as '%s'" + errFmtOIDCClientOptionMustScopeClientType = errFmtOIDCClientOption + errFmtMustBeConfiguredAs + errFmtOIDCWhenScope + " and the '%s' client type but it's configured as '%s'" errFmtOIDCClientInvalidEntriesClientCredentials = errFmtOIDCClientOption + "'scopes' has the values " + - "%s however when exclusively utilizing the 'client_credentials' value for the 'grant_types' the values %s are not allowed" + "%s however when utilizing the 'client_credentials' value for the 'grant_types' the values %s are not allowed" errFmtOIDCClientInvalidEntryDuplicates = errFmtOIDCClientOption + "'%s' must have unique values but the values %s are duplicated" errFmtOIDCClientInvalidValue = errFmtOIDCClientOption + "'%s' must be one of %s but it's configured as '%s'" @@ -367,11 +382,14 @@ const ( errFmtServerPathNotEndForwardSlash = "server: option 'address' must not and with a forward slash but it's configured as '%s'" errFmtServerPathAlphaNum = "server: option 'path' must only contain alpha numeric characters" - errFmtServerEndpointsAuthzImplementation = "server: endpoints: authz: %s: option 'implementation' must be one of %s but it's configured as '%s'" - errFmtServerEndpointsAuthzStrategy = "server: endpoints: authz: %s: authn_strategies: option 'name' must be one of %s but it's configured as '%s'" - errFmtServerEndpointsAuthzStrategyDuplicate = "server: endpoints: authz: %s: authn_strategies: duplicate strategy name detected with name '%s'" - errFmtServerEndpointsAuthzPrefixDuplicate = "server: endpoints: authz: %s: endpoint starts with the same prefix as the '%s' endpoint with the '%s' implementation which accepts prefixes as part of its implementation" - errFmtServerEndpointsAuthzInvalidName = "server: endpoints: authz: %s: contains invalid characters" + errFmtServerEndpointsAuthzImplementation = "server: endpoints: authz: %s: option 'implementation' must be one of %s but it's configured as '%s'" + errFmtServerEndpointsAuthzStrategy = "server: endpoints: authz: %s: authn_strategies: option 'name' must be one of %s but it's configured as '%s'" + errFmtServerEndpointsAuthzSchemes = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' must only include the values %s but has '%s'" + errFmtServerEndpointsAuthzSchemesInvalidForStrategy = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' is not valid for the strategy" + errFmtServerEndpointsAuthzStrategyNoName = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option 'name' must be configured" + errFmtServerEndpointsAuthzStrategyDuplicate = "server: endpoints: authz: %s: authn_strategies: duplicate strategy name detected with name '%s'" + errFmtServerEndpointsAuthzPrefixDuplicate = "server: endpoints: authz: %s: endpoint starts with the same prefix as the '%s' endpoint with the '%s' implementation which accepts prefixes as part of its implementation" + errFmtServerEndpointsAuthzInvalidName = "server: endpoints: authz: %s: contains invalid characters" errFmtServerEndpointsAuthzLegacyInvalidImplementation = "server: endpoints: authz: %s: option 'implementation' is invalid: the endpoint with the name 'legacy' must use the 'Legacy' implementation" ) @@ -421,9 +439,7 @@ const ( ) const ( - legacy = "legacy" - authzImplementationLegacy = "Legacy" - authzImplementationExtAuthz = "ExtAuthz" + legacy = "legacy" ) const ( @@ -431,8 +447,10 @@ const ( ) var ( - validAuthzImplementations = []string{"AuthRequest", "ForwardAuth", authzImplementationExtAuthz, authzImplementationLegacy} - validAuthzAuthnStrategies = []string{"CookieSession", "HeaderAuthorization", "HeaderProxyAuthorization", "HeaderAuthRequestProxyAuthorization", "HeaderLegacy"} + validAuthzImplementations = []string{schema.AuthzImplementationAuthRequest, schema.AuthzImplementationForwardAuth, schema.AuthzImplementationExtAuthz, schema.AuthzImplementationLegacy} + validAuthzAuthnStrategies = []string{schema.AuthzStrategyHeaderCookieSession, schema.AuthzStrategyHeaderAuthorization, schema.AuthzStrategyHeaderProxyAuthorization, schema.AuthzStrategyHeaderAuthRequestProxyAuthorization, schema.AuthzStrategyHeaderLegacy} + validAuthzAuthnHeaderStrategies = []string{schema.AuthzStrategyHeaderAuthorization, schema.AuthzStrategyHeaderProxyAuthorization, schema.AuthzStrategyHeaderAuthRequestProxyAuthorization} + validAuthzAuthnStrategySchemes = []string{schema.SchemeBasic, schema.SchemeBearer} ) var ( @@ -514,7 +532,7 @@ 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} + validOIDCClientScopes = []string{oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeProfile, 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} @@ -527,6 +545,11 @@ var ( validOIDCClientTokenEndpointAuthMethodsConfidential = []string{oidc.ClientAuthMethodClientSecretPost, oidc.ClientAuthMethodClientSecretBasic, oidc.ClientAuthMethodPrivateKeyJWT} validOIDCClientTokenEndpointAuthSigAlgsClientSecretJWT = []string{oidc.SigningAlgHMACUsingSHA256, oidc.SigningAlgHMACUsingSHA384, oidc.SigningAlgHMACUsingSHA512} validOIDCIssuerJWKSigningAlgs = []string{oidc.SigningAlgRSAUsingSHA256, oidc.SigningAlgRSAPSSUsingSHA256, oidc.SigningAlgECDSAUsingP256AndSHA256, oidc.SigningAlgRSAUsingSHA384, oidc.SigningAlgRSAPSSUsingSHA384, oidc.SigningAlgECDSAUsingP384AndSHA384, oidc.SigningAlgRSAUsingSHA512, oidc.SigningAlgRSAPSSUsingSHA512, oidc.SigningAlgECDSAUsingP521AndSHA512} + + validOIDCClientScopesBearerAuthz = []string{oidc.ScopeOfflineAccess, oidc.ScopeOffline, oidc.ScopeAutheliaBearerAuthz} + validOIDCClientResponseModesBearerAuthz = []string{oidc.ResponseModeFormPost, oidc.ResponseModeFormPostJWT} + validOIDCClientResponseTypesBearerAuthz = []string{oidc.ResponseTypeAuthorizationCodeFlow} + validOIDCClientGrantTypesBearerAuthz = []string{oidc.GrantTypeAuthorizationCode, oidc.GrantTypeRefreshToken, oidc.GrantTypeClientCredentials} ) var ( @@ -534,6 +557,7 @@ var ( reDomainCharacters = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)+[a-z0-9]$`) reAuthzEndpointName = regexp.MustCompile(`^[a-zA-Z](([a-zA-Z0-9/._-]*)([a-zA-Z]))?$`) reOpenIDConnectKID = regexp.MustCompile(`^([a-zA-Z0-9](([a-zA-Z0-9._~-]*)([a-zA-Z0-9]))?)?$`) + reRFC3986Unreserved = regexp.MustCompile(`^[a-zA-Z0-9._~-]+$`) ) var replacedKeys = map[string]string{ diff --git a/internal/configuration/validator/identity_providers.go b/internal/configuration/validator/identity_providers.go index 7b86d1b86..c28bbce8d 100644 --- a/internal/configuration/validator/identity_providers.go +++ b/internal/configuration/validator/identity_providers.go @@ -7,7 +7,6 @@ import ( "net/url" "sort" "strconv" - "strings" "github.com/ory/fosite" @@ -120,6 +119,8 @@ func validateOIDCLifespans(config *schema.IdentityProvidersOpenIDConnect, _ *sch func validateOIDCIssuer(config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator) { switch { + case len(config.IssuerPrivateKeys) != 0 && (config.IssuerPrivateKey != nil || config.IssuerCertificateChain.HasCertificates()): + validator.Push(fmt.Errorf("identity_providers: oidc: option `issuer_private_keys` must not be configured at the same time as 'issuer_private_key' or 'issuer_certificate_chain'")) case config.IssuerPrivateKey != nil: validateOIDCIssuerPrivateKey(config) @@ -347,19 +348,26 @@ func validateOIDCClients(config *schema.IdentityProvidersOpenIDConnect, validato errDeprecatedFunc := func() { errDeprecated = true } for c, client := range config.Clients { - if client.ID == "" { + n := len(client.ID) + + switch { + case n == 0: blankClientIDs = append(blankClientIDs, "#"+strconv.Itoa(c+1)) - } else { + case n > 100: + validator.Push(fmt.Errorf(errFmtOIDCClientIDTooLong, client.ID, n)) + case !reRFC3986Unreserved.MatchString(client.ID): + validator.Push(fmt.Errorf(errFmtOIDCClientIDInvalidCharacters, client.ID)) + default: if client.Description == "" { config.Clients[c].Description = client.ID } - if id := strings.ToLower(client.ID); utils.IsStringInSlice(id, clientIDs) { - if !utils.IsStringInSlice(id, duplicateClientIDs) { - duplicateClientIDs = append(duplicateClientIDs, id) + if utils.IsStringInSlice(client.ID, clientIDs) { + if !utils.IsStringInSlice(client.ID, duplicateClientIDs) { + duplicateClientIDs = append(duplicateClientIDs, client.ID) } } else { - clientIDs = append(clientIDs, id) + clientIDs = append(clientIDs, client.ID) } } @@ -380,7 +388,15 @@ func validateOIDCClients(config *schema.IdentityProvidersOpenIDConnect, validato } func validateOIDCClient(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, errDeprecatedFunc func()) { + ccg := utils.IsStringInSlice(oidc.GrantTypeClientCredentials, config.Clients[c].GrantTypes) + switch { + case ccg: + if config.Clients[c].AuthorizationPolicy == "" { + config.Clients[c].AuthorizationPolicy = policyOneFactor + } else if config.Clients[c].AuthorizationPolicy != policyOneFactor { + validator.Push(fmt.Errorf(errFmtOIDCClientInvalidValue, config.Clients[c].ID, "authorization_policy", strJoinOr([]string{policyOneFactor}), config.Clients[c].AuthorizationPolicy)) + } case config.Clients[c].AuthorizationPolicy == "": config.Clients[c].AuthorizationPolicy = schema.DefaultOpenIDConnectClientConfiguration.AuthorizationPolicy case utils.IsStringInSlice(config.Clients[c].AuthorizationPolicy, config.Discovery.AuthorizationPolicies): @@ -416,12 +432,14 @@ func validateOIDCClient(c int, config *schema.IdentityProvidersOpenIDConnect, va validator.Push(fmt.Errorf(errFmtOIDCClientInvalidValue, config.Clients[c].ID, attrOIDCRequestedAudienceMode, strJoinOr([]string{oidc.ClientRequestedAudienceModeExplicit.String(), oidc.ClientRequestedAudienceModeImplicit.String()}), config.Clients[c].RequestedAudienceMode)) } - validateOIDCClientConsentMode(c, config, validator) + setDefaults := validateOIDCClientScopesSpecialBearerAuthz(c, config, ccg, validator) - validateOIDCClientScopes(c, config, validator, errDeprecatedFunc) - validateOIDCClientResponseTypes(c, config, validator, errDeprecatedFunc) - validateOIDCClientResponseModes(c, config, validator, errDeprecatedFunc) - validateOIDCClientGrantTypes(c, config, validator, errDeprecatedFunc) + validateOIDCClientConsentMode(c, config, validator, setDefaults) + + validateOIDCClientScopes(c, config, validator, ccg, errDeprecatedFunc) + validateOIDCClientResponseTypes(c, config, validator, setDefaults, errDeprecatedFunc) + validateOIDCClientResponseModes(c, config, validator, setDefaults, errDeprecatedFunc) + validateOIDCClientGrantTypes(c, config, validator, setDefaults, errDeprecatedFunc) validateOIDCClientRedirectURIs(c, config, validator, errDeprecatedFunc) validateOIDDClientSigningAlgs(c, config, validator) @@ -569,9 +587,13 @@ func validateOIDCClientSectorIdentifier(c int, config *schema.IdentityProvidersO } } -func validateOIDCClientConsentMode(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator) { +func validateOIDCClientConsentMode(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, setDefaults bool) { switch { case utils.IsStringInSlice(config.Clients[c].ConsentMode, []string{"", auto}): + if !setDefaults { + break + } + if config.Clients[c].ConsentPreConfiguredDuration != nil { config.Clients[c].ConsentMode = oidc.ClientConsentModePreConfigured.String() } else { @@ -588,8 +610,8 @@ func validateOIDCClientConsentMode(c int, config *schema.IdentityProvidersOpenID } } -func validateOIDCClientScopes(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, errDeprecatedFunc func()) { - if len(config.Clients[c].Scopes) == 0 { +func validateOIDCClientScopes(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, ccg bool, errDeprecatedFunc func()) { + if len(config.Clients[c].Scopes) == 0 && !ccg { config.Clients[c].Scopes = schema.DefaultOpenIDConnectClientConfiguration.Scopes } @@ -601,16 +623,10 @@ func validateOIDCClientScopes(c int, config *schema.IdentityProvidersOpenIDConne validator.PushWarning(fmt.Errorf(errFmtOIDCClientInvalidEntryDuplicates, config.Clients[c].ID, attrOIDCScopes, strJoinAnd(duplicates))) } - if utils.IsStringInSlice(oidc.GrantTypeClientCredentials, config.Clients[c].GrantTypes) { + if ccg { validateOIDCClientScopesClientCredentialsGrant(c, config, validator) - } else { - if !utils.IsStringInSlice(oidc.ScopeOpenID, config.Clients[c].Scopes) { - config.Clients[c].Scopes = append([]string{oidc.ScopeOpenID}, config.Clients[c].Scopes...) - } - - if len(invalid) != 0 { - validator.Push(fmt.Errorf(errFmtOIDCClientInvalidEntries, config.Clients[c].ID, attrOIDCScopes, strJoinOr(validOIDCClientScopes), strJoinAnd(invalid))) - } + } else if len(invalid) != 0 { + validator.PushWarning(fmt.Errorf(errFmtOIDCClientUnknownScopeEntries, config.Clients[c].ID, attrOIDCScopes, strJoinOr(validOIDCClientScopes), strJoinAnd(invalid))) } if utils.IsStringSliceContainsAny([]string{oidc.ScopeOfflineAccess, oidc.ScopeOffline}, config.Clients[c].Scopes) && @@ -625,11 +641,81 @@ func validateOIDCClientScopes(c int, config *schema.IdentityProvidersOpenIDConne } } -func validateOIDCClientScopesClientCredentialsGrant(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator) { - if len(config.Clients[c].GrantTypes) != 1 { - return +//nolint:gocyclo +func validateOIDCClientScopesSpecialBearerAuthz(c int, config *schema.IdentityProvidersOpenIDConnect, ccg bool, validator *schema.StructValidator) bool { + if !utils.IsStringInSlice(oidc.ScopeAutheliaBearerAuthz, config.Clients[c].Scopes) { + return true + } + + if !config.Discovery.BearerAuthorization { + config.Discovery.BearerAuthorization = true + } + + if !utils.IsStringSliceContainsAll(config.Clients[c].Scopes, validOIDCClientScopesBearerAuthz) { + validator.Push(fmt.Errorf(errFmtOIDCClientInvalidEntriesScope, config.Clients[c].ID, attrOIDCScopes, strJoinAnd(validOIDCClientScopesBearerAuthz), oidc.ScopeAutheliaBearerAuthz, strJoinAnd(config.Clients[c].Scopes))) } + if len(config.Clients[c].GrantTypes) == 0 { + validator.Push(fmt.Errorf(errFmtOIDCClientEmptyEntriesScope, config.Clients[c].ID, attrOIDCGrantTypes, strJoinAnd(validOIDCClientGrantTypesBearerAuthz), oidc.ScopeAutheliaBearerAuthz)) + } else { + invalid, _ := validateList(config.Clients[c].GrantTypes, validOIDCClientGrantTypesBearerAuthz, false) + + if len(invalid) != 0 { + validator.Push(fmt.Errorf(errFmtOIDCClientInvalidEntriesScope, config.Clients[c].ID, attrOIDCGrantTypes, strJoinAnd(validOIDCClientGrantTypesBearerAuthz), oidc.ScopeAutheliaBearerAuthz, strJoinAnd(invalid))) + } + } + + if len(config.Clients[c].Audience) == 0 { + validator.Push(fmt.Errorf(errFmtOIDCClientOptionRequiredScope, config.Clients[c].ID, "audience", oidc.ScopeAutheliaBearerAuthz)) + } + + if !ccg { + if !config.Clients[c].EnforcePAR { + validator.Push(fmt.Errorf(errFmtOIDCClientOptionMustScope, config.Clients[c].ID, "enforce_par", "'true'", oidc.ScopeAutheliaBearerAuthz, "false")) + } + + if !config.Clients[c].EnforcePKCE { + validator.Push(fmt.Errorf(errFmtOIDCClientOptionMustScope, config.Clients[c].ID, "enforce_pkce", "'true'", oidc.ScopeAutheliaBearerAuthz, "false")) + } else if config.Clients[c].PKCEChallengeMethod != oidc.PKCEChallengeMethodSHA256 { + validator.Push(fmt.Errorf(errFmtOIDCClientOptionMustScope, config.Clients[c].ID, attrOIDCPKCEChallengeMethod, "'"+oidc.PKCEChallengeMethodSHA256+"'", oidc.ScopeAutheliaBearerAuthz, config.Clients[c].PKCEChallengeMethod)) + } + + if config.Clients[c].ConsentMode != oidc.ClientConsentModeExplicit.String() { + validator.Push(fmt.Errorf(errFmtOIDCClientOptionMustScope, config.Clients[c].ID, "consent_mode", "'"+oidc.ClientConsentModeExplicit.String()+"'", oidc.ScopeAutheliaBearerAuthz, config.Clients[c].ConsentMode)) + } + + if len(config.Clients[c].ResponseTypes) == 0 { + validator.Push(fmt.Errorf(errFmtOIDCClientEmptyEntriesScope, config.Clients[c].ID, attrOIDCResponseTypes, strJoinAnd(validOIDCClientResponseTypesBearerAuthz), oidc.ScopeAutheliaBearerAuthz)) + } else if !utils.IsStringSliceContainsAll(config.Clients[c].ResponseTypes, validOIDCClientResponseTypesBearerAuthz) || + !utils.IsStringSliceContainsAny(config.Clients[c].ResponseTypes, validOIDCClientResponseTypesBearerAuthz) { + validator.Push(fmt.Errorf(errFmtOIDCClientInvalidEntriesScope, config.Clients[c].ID, attrOIDCResponseTypes, strJoinAnd(validOIDCClientResponseTypesBearerAuthz), oidc.ScopeAutheliaBearerAuthz, strJoinAnd(config.Clients[c].ResponseTypes))) + } + + if len(config.Clients[c].ResponseModes) == 0 { + validator.Push(fmt.Errorf(errFmtOIDCClientEmptyEntriesScope, config.Clients[c].ID, attrOIDCResponseModes, strJoinAnd(validOIDCClientResponseModesBearerAuthz), oidc.ScopeAutheliaBearerAuthz)) + } else if !utils.IsStringSliceContainsAll(config.Clients[c].ResponseModes, validOIDCClientResponseModesBearerAuthz) || + !utils.IsStringSliceContainsAny(config.Clients[c].ResponseModes, validOIDCClientResponseModesBearerAuthz) { + validator.Push(fmt.Errorf(errFmtOIDCClientInvalidEntriesScope, config.Clients[c].ID, attrOIDCResponseModes, strJoinAnd(validOIDCClientResponseModesBearerAuthz), oidc.ScopeAutheliaBearerAuthz, strJoinAnd(config.Clients[c].ResponseModes))) + } + } + + if config.Clients[c].Public { + if config.Clients[c].TokenEndpointAuthMethod != oidc.ClientAuthMethodNone { + validator.Push(fmt.Errorf(errFmtOIDCClientOptionMustScopeClientType, config.Clients[c].ID, attrOIDCTokenAuthMethod, "'"+oidc.ClientAuthMethodNone+"'", oidc.ScopeAutheliaBearerAuthz, "public", config.Clients[c].TokenEndpointAuthMethod)) + } + } else { + switch config.Clients[c].TokenEndpointAuthMethod { + case oidc.ClientAuthMethodClientSecretPost, oidc.ClientAuthMethodClientSecretJWT, oidc.ClientAuthMethodPrivateKeyJWT: + break + default: + validator.Push(fmt.Errorf(errFmtOIDCClientOptionMustScopeClientType, config.Clients[c].ID, attrOIDCTokenAuthMethod, strJoinOr([]string{oidc.ClientAuthMethodClientSecretPost, oidc.ClientAuthMethodClientSecretJWT, oidc.ClientAuthMethodPrivateKeyJWT}), oidc.ScopeAutheliaBearerAuthz, "confidential", config.Clients[c].TokenEndpointAuthMethod)) + } + } + + return false +} + +func validateOIDCClientScopesClientCredentialsGrant(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator) { invalid := validateListNotAllowed(config.Clients[c].Scopes, []string{oidc.ScopeOpenID, oidc.ScopeOffline, oidc.ScopeOfflineAccess}) if len(invalid) > 0 { @@ -637,8 +723,12 @@ func validateOIDCClientScopesClientCredentialsGrant(c int, config *schema.Identi } } -func validateOIDCClientResponseTypes(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, errDeprecatedFunc func()) { +func validateOIDCClientResponseTypes(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, setDefaults bool, errDeprecatedFunc func()) { if len(config.Clients[c].ResponseTypes) == 0 { + if !setDefaults { + return + } + config.Clients[c].ResponseTypes = schema.DefaultOpenIDConnectClientConfiguration.ResponseTypes } @@ -655,8 +745,12 @@ func validateOIDCClientResponseTypes(c int, config *schema.IdentityProvidersOpen } } -func validateOIDCClientResponseModes(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, errDeprecatedFunc func()) { +func validateOIDCClientResponseModes(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, setDefaults bool, errDeprecatedFunc func()) { if len(config.Clients[c].ResponseModes) == 0 { + if !setDefaults { + return + } + config.Clients[c].ResponseModes = schema.DefaultOpenIDConnectClientConfiguration.ResponseModes for _, responseType := range config.Clients[c].ResponseTypes { @@ -687,8 +781,12 @@ func validateOIDCClientResponseModes(c int, config *schema.IdentityProvidersOpen } } -func validateOIDCClientGrantTypes(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, errDeprecatedFunc func()) { +func validateOIDCClientGrantTypes(c int, config *schema.IdentityProvidersOpenIDConnect, validator *schema.StructValidator, setDefaults bool, errDeprecatedFunc func()) { if len(config.Clients[c].GrantTypes) == 0 { + if !setDefaults { + return + } + validateOIDCClientGrantTypesSetDefaults(c, config) } diff --git a/internal/configuration/validator/identity_providers_test.go b/internal/configuration/validator/identity_providers_test.go index b3e276488..a5af2a01d 100644 --- a/internal/configuration/validator/identity_providers_test.go +++ b/internal/configuration/validator/identity_providers_test.go @@ -34,7 +34,31 @@ func TestShouldRaiseErrorWhenInvalidOIDCServerConfiguration(t *testing.T) { require.Len(t, validator.Errors(), 2) - assert.EqualError(t, validator.Errors()[0], "identity_providers: oidc: option `issuer_private_keys` or 'issuer_private_key' is required") + assert.EqualError(t, validator.Errors()[0], "identity_providers: oidc: option `issuer_private_keys` is required") + assert.EqualError(t, validator.Errors()[1], "identity_providers: oidc: option 'clients' must have one or more clients configured") +} + +func TestShouldRaiseErrorWhenInvalidOIDCServerConfigurationBothKeyTypesSpecified(t *testing.T) { + validator := schema.NewStructValidator() + config := &schema.IdentityProviders{ + OIDC: &schema.IdentityProvidersOpenIDConnect{ + HMACSecret: "abc", + IssuerPrivateKey: keyRSA2048, + IssuerPrivateKeys: []schema.JWK{ + { + Use: "sig", + Algorithm: "RS256", + Key: keyRSA4096, + }, + }, + }, + } + + ValidateIdentityProviders(config, validator) + + require.Len(t, validator.Errors(), 2) + + assert.EqualError(t, validator.Errors()[0], "identity_providers: oidc: option `issuer_private_keys` must not be configured at the same time as 'issuer_private_key' or 'issuer_certificate_chain'") assert.EqualError(t, validator.Errors()[1], "identity_providers: oidc: option 'clients' must have one or more clients configured") } @@ -188,6 +212,30 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { }, }, { + name: "BadIDTooLong", + clients: []schema.IdentityProvidersOpenIDConnectClient{ + { + ID: "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123", + Secret: tOpenIDConnectPlainTextClientSecret, + }, + }, + errors: []string{ + "identity_providers: oidc: clients: client 'abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123': option 'id' must not be more than 100 characters but it has 108 characters", + }, + }, + { + name: "BadIDInvalidCharacters", + clients: []schema.IdentityProvidersOpenIDConnectClient{ + { + ID: "@!#!@$!@#*()!&@%*(!^@#*()!&@^%!(_@#&", + Secret: tOpenIDConnectPlainTextClientSecret, + }, + }, + errors: []string{ + "identity_providers: oidc: clients: client '@!#!@$!@#*()!&@%*(!^@#*()!&@^%!(_@#&': option 'id' must only contain RFC3986 unreserved characters", + }, + }, + { name: "InvalidPolicy", clients: []schema.IdentityProvidersOpenIDConnectClient{ { @@ -204,6 +252,25 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { }, }, { + name: "InvalidPolicyCCG", + clients: []schema.IdentityProvidersOpenIDConnectClient{ + { + ID: "client-1", + Secret: tOpenIDConnectPlainTextClientSecret, + AuthorizationPolicy: "a-policy", + RedirectURIs: []string{ + "https://google.com", + }, + GrantTypes: []string{ + oidc.GrantTypeClientCredentials, + }, + }, + }, + errors: []string{ + "identity_providers: oidc: clients: client 'client-1': option 'authorization_policy' must be one of 'one_factor' but it's configured as 'a-policy'", + }, + }, + { name: "ClientIDDuplicated", clients: []schema.IdentityProvidersOpenIDConnectClient{ { @@ -453,32 +520,6 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { } } -func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadScopes(t *testing.T) { - validator := schema.NewStructValidator() - config := &schema.IdentityProviders{ - OIDC: &schema.IdentityProvidersOpenIDConnect{ - HMACSecret: "rLABDrx87et5KvRHVUgTm3pezWWd8LMN", - IssuerPrivateKey: keyRSA2048, - Clients: []schema.IdentityProvidersOpenIDConnectClient{ - { - ID: "good_id", - Secret: tOpenIDConnectPlainTextClientSecret, - AuthorizationPolicy: "two_factor", - Scopes: []string{"openid", "bad_scope"}, - RedirectURIs: []string{ - "https://google.com/callback", - }, - }, - }, - }, - } - - ValidateIdentityProviders(config, validator) - - require.Len(t, validator.Errors(), 1) - assert.EqualError(t, validator.Errors()[0], "identity_providers: oidc: clients: client 'good_id': option 'scopes' must only have the values 'openid', 'email', 'profile', 'groups', or 'offline_access' but the values 'bad_scope' are present") -} - func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadGrantTypes(t *testing.T) { validator := schema.NewStructValidator() config := &schema.IdentityProviders{ @@ -858,7 +899,7 @@ func TestValidateOIDCClients(t *testing.T) { nil, }, { - "ShouldIncludeMinimalScope", + "ShouldNotIncludeOldMinimalScope", nil, nil, tcv{ @@ -868,7 +909,7 @@ func TestValidateOIDCClients(t *testing.T) { nil, }, tcv{ - []string{oidc.ScopeOpenID, oidc.ScopeEmail}, + []string{oidc.ScopeEmail}, []string{oidc.ResponseTypeAuthorizationCodeFlow}, []string{oidc.ResponseModeFormPost, oidc.ResponseModeQuery}, []string{oidc.GrantTypeAuthorizationCode}, @@ -1031,7 +1072,7 @@ func TestValidateOIDCClients(t *testing.T) { nil, }, { - "ShouldRaiseErrorOnInvalidScopes", + "ShouldRaiseWarningOnInvalidScopes", nil, nil, tcv{ @@ -1046,10 +1087,10 @@ func TestValidateOIDCClients(t *testing.T) { []string{oidc.ResponseModeFormPost, oidc.ResponseModeQuery}, []string{oidc.GrantTypeAuthorizationCode}, }, - nil, []string{ - "identity_providers: oidc: clients: client 'test': option 'scopes' must only have the values 'openid', 'email', 'profile', 'groups', or 'offline_access' but the values 'group' are present", + "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", }, + nil, }, { "ShouldRaiseErrorOnMissingAuthorizationCodeFlowResponseTypeWithRefreshTokenValues", @@ -1290,33 +1331,8 @@ func TestValidateOIDCClients(t *testing.T) { "identity_providers: oidc: clients: client 'test': option 'scopes' should only have the values 'offline_access' or 'offline' if the client is also configured with a 'response_type' such as 'code', 'code id_token', 'code token', or 'code id_token token' which respond with authorization codes", }, []string{ - "identity_providers: oidc: clients: client 'test': option 'scopes' has the values 'openid', 'offline', and 'offline_access' however when exclusively utilizing the 'client_credentials' value for the 'grant_types' the values 'openid', 'offline', or 'offline_access' are not allowed", - }, - }, - { - "ShouldNotRestrictRefreshOpenIDScopesWithMultipleGrantTypesAndAllowCustomClientCredentials", - func(have *schema.IdentityProvidersOpenIDConnect) { - have.Clients[0].Public = false - have.Clients[0].Scopes = []string{oidc.ScopeOpenID, oidc.ScopeOffline, oidc.ScopeOfflineAccess, "custom"} - }, - nil, - tcv{ - nil, - nil, - nil, - []string{oidc.GrantTypeClientCredentials, oidc.GrantTypeImplicit}, - }, - tcv{ - []string{oidc.ScopeOpenID, oidc.ScopeOffline, oidc.ScopeOfflineAccess, "custom"}, - []string{oidc.ResponseTypeAuthorizationCodeFlow}, - []string{oidc.ResponseModeFormPost, oidc.ResponseModeQuery}, - []string{oidc.GrantTypeClientCredentials, oidc.GrantTypeImplicit}, - }, - []string{ - "identity_providers: oidc: clients: client 'test': option 'scopes' should only have the values 'offline_access' or 'offline' if the client is also configured with a 'response_type' such as 'code', 'code id_token', 'code token', or 'code id_token token' which respond with authorization codes", - "identity_providers: oidc: clients: client 'test': option 'grant_types' should only have grant type values which are valid with the configured 'response_types' for the client but 'implicit' expects a response type for either the implicit or hybrid flow such as 'id_token', 'token', 'id_token token', 'code id_token', 'code token', or 'code id_token token' but the response types are 'code'", + "identity_providers: oidc: clients: client 'test': option 'scopes' has the values 'openid', 'offline', and 'offline_access' however when utilizing the 'client_credentials' value for the 'grant_types' the values 'openid', 'offline', or 'offline_access' are not allowed", }, - nil, }, { "ShouldRaiseErrorOnGrantTypeRefreshTokenWithoutScopeOfflineAccess", @@ -2062,6 +2078,262 @@ func TestValidateOIDCClients(t *testing.T) { }, }, { + "ShouldHandleBearerErrorsMisconfiguredPublicClientType", + func(have *schema.IdentityProvidersOpenIDConnect) { + have.Clients[0] = schema.IdentityProvidersOpenIDConnectClient{ + ID: "abc", + Secret: nil, + Public: true, + RedirectURIs: []string{"http://localhost"}, + Audience: nil, + Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOpenID}, + GrantTypes: []string{oidc.GrantTypeImplicit}, + ResponseTypes: []string{oidc.ResponseTypeImplicitFlowBoth}, + ResponseModes: []string{oidc.ResponseModeQuery}, + AuthorizationPolicy: "", + RequestedAudienceMode: "", + ConsentMode: oidc.ClientConsentModeImplicit.String(), + EnforcePAR: false, + EnforcePKCE: false, + PKCEChallengeMethod: "", + TokenEndpointAuthMethod: "", + } + }, + nil, + tcv{ + nil, + nil, + nil, + nil, + }, + tcv{ + []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOpenID}, + []string{oidc.ResponseTypeImplicitFlowBoth}, + []string{oidc.ResponseModeQuery}, + []string{oidc.GrantTypeImplicit}, + }, + nil, + []string{ + "identity_providers: oidc: clients: client 'abc': option 'scopes' must only have the values 'offline_access', 'offline', and 'authelia.bearer.authz' when configured with scope 'authelia.bearer.authz' but the values 'authelia.bearer.authz' and 'openid' are present", + "identity_providers: oidc: clients: client 'abc': option 'grant_types' must only have the values 'authorization_code', 'refresh_token', and 'client_credentials' when configured with scope 'authelia.bearer.authz' but the values 'implicit' are present", + "identity_providers: oidc: clients: client 'abc': option 'audience' must be configured when configured with scope 'authelia.bearer.authz' but it's absent", + "identity_providers: oidc: clients: client 'abc': option 'enforce_par' must be configured as 'true' when configured with scope 'authelia.bearer.authz' but it's configured as 'false'", + "identity_providers: oidc: clients: client 'abc': option 'enforce_pkce' must be configured as 'true' when configured with scope 'authelia.bearer.authz' but it's configured as 'false'", + "identity_providers: oidc: clients: client 'abc': option 'consent_mode' must be configured as 'explicit' when configured with scope 'authelia.bearer.authz' but it's configured as 'implicit'", + "identity_providers: oidc: clients: client 'abc': option 'response_types' must only have the values 'code' when configured with scope 'authelia.bearer.authz' but the values 'id_token token' are present", + "identity_providers: oidc: clients: client 'abc': option 'response_modes' must only have the values 'form_post' and 'form_post.jwt' when configured with scope 'authelia.bearer.authz' but the values 'query' are present", + "identity_providers: oidc: clients: client 'abc': option 'token_endpoint_auth_method' must be configured as 'none' when configured with scope 'authelia.bearer.authz' and the 'public' client type but it's configured as ''", + }, + }, + { + "ShouldHandleBearerErrorsMisconfiguredConfidentialClientType", + func(have *schema.IdentityProvidersOpenIDConnect) { + have.Clients[0] = schema.IdentityProvidersOpenIDConnectClient{ + ID: "abc", + Secret: tOpenIDConnectPBKDF2ClientSecret, + Public: false, + RedirectURIs: []string{"http://localhost"}, + Audience: nil, + Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOpenID}, + GrantTypes: []string{oidc.GrantTypeImplicit}, + ResponseTypes: []string{oidc.ResponseTypeImplicitFlowBoth}, + ResponseModes: []string{oidc.ResponseModeQuery}, + AuthorizationPolicy: "", + RequestedAudienceMode: "", + ConsentMode: oidc.ClientConsentModeImplicit.String(), + EnforcePAR: false, + EnforcePKCE: true, + PKCEChallengeMethod: "", + TokenEndpointAuthMethod: "", + } + }, + nil, + tcv{ + nil, + nil, + nil, + nil, + }, + tcv{ + []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOpenID}, + []string{oidc.ResponseTypeImplicitFlowBoth}, + []string{oidc.ResponseModeQuery}, + []string{oidc.GrantTypeImplicit}, + }, + nil, + []string{ + "identity_providers: oidc: clients: client 'abc': option 'scopes' must only have the values 'offline_access', 'offline', and 'authelia.bearer.authz' when configured with scope 'authelia.bearer.authz' but the values 'authelia.bearer.authz' and 'openid' are present", + "identity_providers: oidc: clients: client 'abc': option 'grant_types' must only have the values 'authorization_code', 'refresh_token', and 'client_credentials' when configured with scope 'authelia.bearer.authz' but the values 'implicit' are present", + "identity_providers: oidc: clients: client 'abc': option 'audience' must be configured when configured with scope 'authelia.bearer.authz' but it's absent", + "identity_providers: oidc: clients: client 'abc': option 'enforce_par' must be configured as 'true' when configured with scope 'authelia.bearer.authz' but it's configured as 'false'", + "identity_providers: oidc: clients: client 'abc': option 'pkce_challenge_method' must be configured as 'S256' when configured with scope 'authelia.bearer.authz' but it's configured as ''", + "identity_providers: oidc: clients: client 'abc': option 'consent_mode' must be configured as 'explicit' when configured with scope 'authelia.bearer.authz' but it's configured as 'implicit'", + "identity_providers: oidc: clients: client 'abc': option 'response_types' must only have the values 'code' when configured with scope 'authelia.bearer.authz' but the values 'id_token token' are present", + "identity_providers: oidc: clients: client 'abc': option 'response_modes' must only have the values 'form_post' and 'form_post.jwt' when configured with scope 'authelia.bearer.authz' but the values 'query' are present", + "identity_providers: oidc: clients: client 'abc': option 'token_endpoint_auth_method' must be configured as 'client_secret_post', 'client_secret_jwt', or 'private_key_jwt' when configured with scope 'authelia.bearer.authz' and the 'confidential' client type but it's configured as ''", + }, + }, + { + "ShouldHandleBearerErrorsMisconfiguredConfidentialClientTypeClientCredentials", + func(have *schema.IdentityProvidersOpenIDConnect) { + have.Clients[0] = schema.IdentityProvidersOpenIDConnectClient{ + ID: "abc", + Secret: tOpenIDConnectPBKDF2ClientSecret, + Public: false, + RedirectURIs: []string{"http://localhost"}, + Audience: nil, + Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOpenID}, + GrantTypes: []string{oidc.GrantTypeClientCredentials}, + ResponseTypes: nil, + ResponseModes: nil, + AuthorizationPolicy: "", + RequestedAudienceMode: "", + ConsentMode: oidc.ClientConsentModeImplicit.String(), + EnforcePAR: false, + EnforcePKCE: true, + PKCEChallengeMethod: "", + TokenEndpointAuthMethod: "", + } + }, + nil, + tcv{ + nil, + nil, + nil, + nil, + }, + tcv{ + []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOpenID}, + []string(nil), + []string(nil), + []string{oidc.GrantTypeClientCredentials}, + }, + nil, + []string{ + "identity_providers: oidc: clients: client 'abc': option 'scopes' must only have the values 'offline_access', 'offline', and 'authelia.bearer.authz' when configured with scope 'authelia.bearer.authz' but the values 'authelia.bearer.authz' and 'openid' are present", + "identity_providers: oidc: clients: client 'abc': option 'audience' must be configured when configured with scope 'authelia.bearer.authz' but it's absent", + "identity_providers: oidc: clients: client 'abc': option 'token_endpoint_auth_method' must be configured as 'client_secret_post', 'client_secret_jwt', or 'private_key_jwt' when configured with scope 'authelia.bearer.authz' and the 'confidential' client type but it's configured as ''", + "identity_providers: oidc: clients: client 'abc': option 'scopes' has the values 'authelia.bearer.authz' and 'openid' however when utilizing the 'client_credentials' value for the 'grant_types' the values 'openid' are not allowed", + }, + }, + { + "ShouldHandleBearerErrorsNotExplicit", + func(have *schema.IdentityProvidersOpenIDConnect) { + have.Clients[0] = schema.IdentityProvidersOpenIDConnectClient{ + ID: "abc", + Secret: nil, + Public: true, + RedirectURIs: []string{"http://localhost"}, + Audience: nil, + Scopes: []string{oidc.ScopeAutheliaBearerAuthz}, + EnforcePAR: false, + EnforcePKCE: false, + PKCEChallengeMethod: "", + TokenEndpointAuthMethod: "", + } + }, + nil, + tcv{ + nil, + nil, + nil, + nil, + }, + tcv{ + []string{oidc.ScopeAutheliaBearerAuthz}, + nil, + nil, + nil, + }, + nil, + []string{ + "identity_providers: oidc: clients: client 'abc': option 'grant_types' must only have the values 'authorization_code', 'refresh_token', and 'client_credentials' when configured with scope 'authelia.bearer.authz' but it's not configured", + "identity_providers: oidc: clients: client 'abc': option 'audience' must be configured when configured with scope 'authelia.bearer.authz' but it's absent", + "identity_providers: oidc: clients: client 'abc': option 'enforce_par' must be configured as 'true' when configured with scope 'authelia.bearer.authz' but it's configured as 'false'", + "identity_providers: oidc: clients: client 'abc': option 'enforce_pkce' must be configured as 'true' when configured with scope 'authelia.bearer.authz' but it's configured as 'false'", + "identity_providers: oidc: clients: client 'abc': option 'consent_mode' must be configured as 'explicit' when configured with scope 'authelia.bearer.authz' but it's configured as ''", + "identity_providers: oidc: clients: client 'abc': option 'response_types' must only have the values 'code' when configured with scope 'authelia.bearer.authz' but it's not configured", + "identity_providers: oidc: clients: client 'abc': option 'response_modes' must only have the values 'form_post' and 'form_post.jwt' when configured with scope 'authelia.bearer.authz' but it's not configured", + "identity_providers: oidc: clients: client 'abc': option 'token_endpoint_auth_method' must be configured as 'none' when configured with scope 'authelia.bearer.authz' and the 'public' client type but it's configured as ''", + }, + }, + { + "ShouldHandleBearerValidConfidentialClientType", + func(have *schema.IdentityProvidersOpenIDConnect) { + have.Clients[0] = schema.IdentityProvidersOpenIDConnectClient{ + ID: "abc", + Secret: tOpenIDConnectPBKDF2ClientSecret, + Public: false, + RedirectURIs: []string{"http://localhost"}, + Audience: []string{"https://app.example.com"}, + Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + GrantTypes: []string{oidc.GrantTypeAuthorizationCode, oidc.GrantTypeRefreshToken}, + ResponseTypes: []string{oidc.ResponseTypeAuthorizationCodeFlow}, + ResponseModes: []string{oidc.ResponseModeFormPost, oidc.ResponseModeFormPostJWT}, + AuthorizationPolicy: "", + RequestedAudienceMode: "", + ConsentMode: oidc.ClientConsentModeExplicit.String(), + EnforcePAR: true, + EnforcePKCE: true, + PKCEChallengeMethod: oidc.PKCEChallengeMethodSHA256, + TokenEndpointAuthMethod: oidc.ClientAuthMethodClientSecretPost, + } + }, + nil, + tcv{ + nil, + nil, + nil, + nil, + }, + tcv{ + []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + []string{oidc.ResponseTypeAuthorizationCodeFlow}, + []string{oidc.ResponseModeFormPost, oidc.ResponseModeFormPostJWT}, + []string{oidc.GrantTypeAuthorizationCode, oidc.GrantTypeRefreshToken}, + }, + nil, + nil, + }, + { + "ShouldHandleBearerValidPublicClientType", + func(have *schema.IdentityProvidersOpenIDConnect) { + have.Clients[0] = schema.IdentityProvidersOpenIDConnectClient{ + ID: "abc", + Secret: nil, + Public: true, + RedirectURIs: []string{"http://localhost"}, + Audience: []string{"https://app.example.com"}, + Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + GrantTypes: []string{oidc.GrantTypeAuthorizationCode, oidc.GrantTypeRefreshToken}, + ResponseTypes: []string{oidc.ResponseTypeAuthorizationCodeFlow}, + ResponseModes: []string{oidc.ResponseModeFormPost}, + AuthorizationPolicy: "", + RequestedAudienceMode: "", + ConsentMode: oidc.ClientConsentModeExplicit.String(), + EnforcePAR: true, + EnforcePKCE: true, + PKCEChallengeMethod: oidc.PKCEChallengeMethodSHA256, + TokenEndpointAuthMethod: oidc.ClientAuthMethodNone, + } + }, + nil, + tcv{ + nil, + nil, + nil, + nil, + }, + tcv{ + []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + []string{oidc.ResponseTypeAuthorizationCodeFlow}, + []string{oidc.ResponseModeFormPost}, + []string{oidc.GrantTypeAuthorizationCode, oidc.GrantTypeRefreshToken}, + }, + nil, + nil, + }, + { "ShouldSetDefaultConsentMode", nil, func(t *testing.T, have *schema.IdentityProvidersOpenIDConnect) { diff --git a/internal/configuration/validator/server.go b/internal/configuration/validator/server.go index 90c4ab744..89b96d6ab 100644 --- a/internal/configuration/validator/server.go +++ b/internal/configuration/validator/server.go @@ -174,7 +174,7 @@ func ValidateServerEndpoints(config *schema.Configuration, validator *schema.Str } switch oEndpoint.Implementation { - case authzImplementationLegacy, authzImplementationExtAuthz: + case schema.AuthzImplementationLegacy, schema.AuthzImplementationExtAuthz: if strings.HasPrefix(name, oName+"/") { validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzPrefixDuplicate, name, oName, oEndpoint.Implementation)) } @@ -183,17 +183,17 @@ func ValidateServerEndpoints(config *schema.Configuration, validator *schema.Str } } - validateServerEndpointsAuthzStrategies(name, endpoint.AuthnStrategies, validator) + validateServerEndpointsAuthzStrategies(name, endpoint.Implementation, endpoint.AuthnStrategies, validator) } } func validateServerEndpointsAuthzEndpoint(config *schema.Configuration, name string, endpoint schema.ServerEndpointsAuthz, validator *schema.StructValidator) { if name == legacy { switch endpoint.Implementation { - case authzImplementationLegacy: + case schema.AuthzImplementationLegacy: break case "": - endpoint.Implementation = authzImplementationLegacy + endpoint.Implementation = schema.AuthzImplementationLegacy config.Server.Endpoints.Authz[name] = endpoint default: @@ -212,18 +212,55 @@ func validateServerEndpointsAuthzEndpoint(config *schema.Configuration, name str } } -func validateServerEndpointsAuthzStrategies(name string, strategies []schema.ServerEndpointsAuthzAuthnStrategy, validator *schema.StructValidator) { +//nolint:gocyclo +func validateServerEndpointsAuthzStrategies(name, implementation string, strategies []schema.ServerEndpointsAuthzAuthnStrategy, validator *schema.StructValidator) { + var defaults []schema.ServerEndpointsAuthzAuthnStrategy + + switch implementation { + case schema.AuthzImplementationLegacy: + defaults = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameLegacy].AuthnStrategies + case schema.AuthzImplementationAuthRequest: + defaults = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameAuthRequest].AuthnStrategies + case schema.AuthzImplementationExtAuthz: + defaults = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameExtAuthz].AuthnStrategies + case schema.AuthzImplementationForwardAuth: + defaults = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].AuthnStrategies + } + + if len(strategies) == 0 { + copy(strategies, defaults) + + return + } + names := make([]string, len(strategies)) - for _, strategy := range strategies { - if utils.IsStringInSlice(strategy.Name, names) { + for i, strategy := range strategies { + if strategy.Name != "" && utils.IsStringInSlice(strategy.Name, names) { validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzStrategyDuplicate, name, strategy.Name)) } names = append(names, strategy.Name) - if !utils.IsStringInSlice(strategy.Name, validAuthzAuthnStrategies) { + switch { + case strategy.Name == "": + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzStrategyNoName, name, i+1)) + case !utils.IsStringInSlice(strategy.Name, validAuthzAuthnStrategies): validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzStrategy, name, strJoinOr(validAuthzAuthnStrategies), strategy.Name)) + default: + if utils.IsStringInSlice(strategy.Name, validAuthzAuthnHeaderStrategies) { + if len(strategy.Schemes) == 0 { + strategies[i].Schemes = defaults[0].Schemes + } else { + for _, scheme := range strategy.Schemes { + if !utils.IsStringInSliceFold(scheme, validAuthzAuthnStrategySchemes) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzSchemes, name, i+1, strategy.Name, strJoinOr(validAuthzAuthnStrategySchemes), scheme)) + } + } + } + } else if len(strategy.Schemes) != 0 { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzSchemesInvalidForStrategy, name, i+1, strategy.Name)) + } } } } diff --git a/internal/configuration/validator/server_test.go b/internal/configuration/validator/server_test.go index c425bb420..231efe9c2 100644 --- a/internal/configuration/validator/server_test.go +++ b/internal/configuration/validator/server_test.go @@ -489,6 +489,38 @@ func TestServerAuthzEndpointErrors(t *testing.T) { []string{"server: endpoints: authz: example: authn_strategies: duplicate strategy name detected with name 'CookieSession'"}, }, { + "ShouldErrorOnSchemesForInvalidStrategy", + map[string]schema.ServerEndpointsAuthz{ + "example": {Implementation: "ForwardAuth", AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{{Name: "CookieSession", Schemes: []string{"basic"}}}}, + }, + []string{"server: endpoints: authz: example: authn_strategies: strategy #1 (CookieSession): option 'schemes' is not valid for the strategy"}, + }, + { + "ShouldNotErrorOnSchemeCase", + map[string]schema.ServerEndpointsAuthz{ + "example": {Implementation: "ForwardAuth", AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{{Name: "HeaderAuthorization", Schemes: []string{"basIc"}}}}, + }, + nil, + }, + { + "ShouldErrorOnInvalidStrategySchemesAndUnnamedStrategy", + map[string]schema.ServerEndpointsAuthz{ + "example": {Implementation: "ForwardAuth", AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{{Name: "HeaderAuthorization", Schemes: []string{"basic", "bearer", "abc"}}}}, + }, + []string{ + "server: endpoints: authz: example: authn_strategies: strategy #1 (HeaderAuthorization): option 'schemes' must only include the values 'basic' or 'bearer' but has 'abc'", + }, + }, + { + "ShouldErrorOnUnnamedStrategy", + map[string]schema.ServerEndpointsAuthz{ + "example": {Implementation: "ForwardAuth", AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{{Name: "", Schemes: []string{"basic", "bearer", "abc"}}}}, + }, + []string{ + "server: endpoints: authz: example: authn_strategies: strategy #1: option 'name' must be configured", + }, + }, + { "ShouldErrorOnInvalidChars", map[string]schema.ServerEndpointsAuthz{ "/abc": {Implementation: "ForwardAuth"}, @@ -567,6 +599,53 @@ func TestServerAuthzEndpointErrors(t *testing.T) { } } +func TestServerAuthzEndpointDefaults(t *testing.T) { + testCases := []struct { + name string + have map[string]schema.ServerEndpointsAuthz + expected map[string]schema.ServerEndpointsAuthz + }{ + { + "ShouldSetDefaultSchemes", + map[string]schema.ServerEndpointsAuthz{ + "example": {Implementation: "ForwardAuth", AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{ + { + Name: "HeaderAuthorization", + Schemes: []string{}, + }, + }}, + }, + map[string]schema.ServerEndpointsAuthz{ + "example": {Implementation: "ForwardAuth", AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{ + { + Name: "HeaderAuthorization", + Schemes: []string{"basic"}, + }, + }}, + }, + }, + } + + validator := schema.NewStructValidator() + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + validator.Clear() + + config := newDefaultConfig() + + config.Server.Endpoints.Authz = tc.have + + ValidateServerEndpoints(&config, validator) + + assert.Len(t, validator.Warnings(), 0) + assert.Len(t, validator.Errors(), 0) + + assert.Equal(t, tc.expected, config.Server.Endpoints.Authz) + }) + } +} + func TestServerAuthzEndpointLegacyAsImplementationLegacyWhenBlank(t *testing.T) { have := map[string]schema.ServerEndpointsAuthz{ "legacy": {}, @@ -583,7 +662,7 @@ func TestServerAuthzEndpointLegacyAsImplementationLegacyWhenBlank(t *testing.T) assert.Len(t, validator.Warnings(), 0) assert.Len(t, validator.Errors(), 0) - assert.Equal(t, authzImplementationLegacy, config.Server.Endpoints.Authz[legacy].Implementation) + assert.Equal(t, schema.AuthzImplementationLegacy, config.Server.Endpoints.Authz[legacy].Implementation) } func TestValidateTLSPathStatInvalidArgument(t *testing.T) { diff --git a/internal/configuration/validator/util.go b/internal/configuration/validator/util.go index 67403dd90..0d6955570 100644 --- a/internal/configuration/validator/util.go +++ b/internal/configuration/validator/util.go @@ -92,7 +92,7 @@ func validateListNotAllowed(values, filter []string) (invalid []string) { return invalid } -func validateList(values, valid []string, chkDuplicate bool) (invalid, duplicates []string) { //nolint:unparam +func validateList(values, valid []string, chkDuplicate bool) (invalid, duplicates []string) { chkValid := len(valid) != 0 for i, value := range values { diff --git a/internal/handlers/handler_authz.go b/internal/handlers/handler_authz.go index 859958aac..b1935863e 100644 --- a/internal/handlers/handler_authz.go +++ b/internal/handlers/handler_authz.go @@ -53,11 +53,11 @@ func (authz *Authz) Handler(ctx *middlewares.AutheliaCtx) { } var ( - authn Authn + authn *Authn strategy AuthnStrategy ) - if authn, strategy, err = authz.authn(ctx, provider); err != nil { + if authn, strategy, err = authz.authn(ctx, provider, &object); err != nil { authn.Object = object ctx.Logger.WithError(err).Error("Error occurred while attempting to authenticate a request") @@ -66,7 +66,7 @@ func (authz *Authz) Handler(ctx *middlewares.AutheliaCtx) { case nil: ctx.ReplyUnauthorized() default: - strategy.HandleUnauthorized(ctx, &authn, authz.getRedirectionURL(&object, autheliaURL)) + strategy.HandleUnauthorized(ctx, authn, authz.getRedirectionURL(&object, autheliaURL)) } return @@ -79,6 +79,7 @@ func (authz *Authz) Handler(ctx *middlewares.AutheliaCtx) { authorization.Subject{ Username: authn.Details.Username, Groups: authn.Details.Groups, + ClientID: authn.ClientID, IP: ctx.RemoteIP(), }, object, @@ -97,9 +98,9 @@ func (authz *Authz) Handler(ctx *middlewares.AutheliaCtx) { handler = authz.handleUnauthorized } - handler(ctx, &authn, authz.getRedirectionURL(&object, autheliaURL)) + handler(ctx, authn, authz.getRedirectionURL(&object, autheliaURL)) case AuthzResultAuthorized: - authz.handleAuthorized(ctx, &authn) + authz.handleAuthorized(ctx, authn) } } @@ -151,14 +152,20 @@ func (authz *Authz) getRedirectionURL(object *authorization.Object, autheliaURL return redirectionURL } -func (authz *Authz) authn(ctx *middlewares.AutheliaCtx, provider *session.Session) (authn Authn, strategy AuthnStrategy, err error) { +func (authz *Authz) authn(ctx *middlewares.AutheliaCtx, provider *session.Session, object *authorization.Object) (authn *Authn, strategy AuthnStrategy, err error) { for _, strategy = range authz.strategies { - if authn, err = strategy.Get(ctx, provider); err != nil { + if authn, err = strategy.Get(ctx, provider, object); err != nil { + // Ensure an error returned can never result in an authenticated user. + authn.Level = authentication.NotAuthenticated + authn.Username = anonymous + authn.ClientID = "" + authn.Details = authentication.UserDetails{} + if strategy.CanHandleUnauthorized() { - return Authn{Type: authn.Type, Level: authentication.NotAuthenticated, Username: anonymous}, strategy, err + return authn, strategy, err } - return Authn{Type: authn.Type, Level: authentication.NotAuthenticated, Username: anonymous}, nil, err + return authn, nil, err } if authn.Level != authentication.NotAuthenticated { diff --git a/internal/handlers/handler_authz_authn.go b/internal/handlers/handler_authz_authn.go index 853e98a5c..5a8b80224 100644 --- a/internal/handlers/handler_authz_authn.go +++ b/internal/handlers/handler_authz_authn.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "context" "encoding/base64" "errors" "fmt" @@ -9,12 +10,16 @@ import ( "strings" "time" + "github.com/ory/fosite" "github.com/sirupsen/logrus" "github.com/valyala/fasthttp" "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/configuration/schema" "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" "github.com/authelia/authelia/v4/internal/utils" ) @@ -28,38 +33,41 @@ func NewCookieSessionAuthnStrategy(refresh schema.RefreshIntervalDuration) *Cook // NewHeaderAuthorizationAuthnStrategy creates a new HeaderAuthnStrategy using the Authorization and WWW-Authenticate // headers, and the 407 Proxy Auth Required response. -func NewHeaderAuthorizationAuthnStrategy() *HeaderAuthnStrategy { +func NewHeaderAuthorizationAuthnStrategy(schemes ...string) *HeaderAuthnStrategy { return &HeaderAuthnStrategy{ authn: AuthnTypeAuthorization, headerAuthorize: headerAuthorization, headerAuthenticate: headerWWWAuthenticate, handleAuthenticate: true, statusAuthenticate: fasthttp.StatusUnauthorized, + schemes: model.NewAuthorizationSchemes(schemes...), } } // NewHeaderProxyAuthorizationAuthnStrategy creates a new HeaderAuthnStrategy using the Proxy-Authorization and // Proxy-Authenticate headers, and the 407 Proxy Auth Required response. -func NewHeaderProxyAuthorizationAuthnStrategy() *HeaderAuthnStrategy { +func NewHeaderProxyAuthorizationAuthnStrategy(schemes ...string) *HeaderAuthnStrategy { return &HeaderAuthnStrategy{ authn: AuthnTypeProxyAuthorization, headerAuthorize: headerProxyAuthorization, headerAuthenticate: headerProxyAuthenticate, handleAuthenticate: true, statusAuthenticate: fasthttp.StatusProxyAuthRequired, + schemes: model.NewAuthorizationSchemes(schemes...), } } // NewHeaderProxyAuthorizationAuthRequestAuthnStrategy creates a new HeaderAuthnStrategy using the Proxy-Authorization // and WWW-Authenticate headers, and the 401 Proxy Auth Required response. This is a special AuthnStrategy for the // AuthRequest implementation. -func NewHeaderProxyAuthorizationAuthRequestAuthnStrategy() *HeaderAuthnStrategy { +func NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(schemes ...string) *HeaderAuthnStrategy { return &HeaderAuthnStrategy{ authn: AuthnTypeProxyAuthorization, headerAuthorize: headerProxyAuthorization, headerAuthenticate: headerWWWAuthenticate, handleAuthenticate: true, statusAuthenticate: fasthttp.StatusUnauthorized, + schemes: model.NewAuthorizationSchemes(schemes...), } } @@ -74,10 +82,10 @@ type CookieSessionAuthnStrategy struct { } // Get returns the Authn information for this AuthnStrategy. -func (s *CookieSessionAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, provider *session.Session) (authn Authn, err error) { +func (s *CookieSessionAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, provider *session.Session, _ *authorization.Object) (authn *Authn, err error) { var userSession session.UserSession - authn = Authn{ + authn = &Authn{ Type: AuthnTypeCookie, Level: authentication.NotAuthenticated, Username: anonymous, @@ -120,7 +128,7 @@ func (s *CookieSessionAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, provider ctx.Logger.WithError(err).Error("Unable to save updated user session") } - return Authn{ + return &Authn{ Username: friendlyUsername(userSession.Username), Details: authentication.UserDetails{ Username: userSession.Username, @@ -149,75 +157,118 @@ type HeaderAuthnStrategy struct { headerAuthenticate []byte handleAuthenticate bool statusAuthenticate int + schemes model.AuthorizationSchemes } // Get returns the Authn information for this AuthnStrategy. -func (s *HeaderAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, _ *session.Session) (authn Authn, err error) { - var ( - username, password string - value []byte - ) +func (s *HeaderAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, _ *session.Session, object *authorization.Object) (authn *Authn, err error) { + var value []byte - authn = Authn{ + authn = &Authn{ Type: s.authn, Level: authentication.NotAuthenticated, Username: anonymous, } - if value = ctx.Request.Header.PeekBytes(s.headerAuthorize); value == nil { + if value = ctx.Request.Header.PeekBytes(s.headerAuthorize); len(value) == 0 { return authn, nil } - if username, password, err = headerAuthorizationParse(value); err != nil { + authz := model.NewAuthorization() + + if err = authz.ParseBytes(value); err != nil { return authn, fmt.Errorf("failed to parse content of %s header: %w", s.headerAuthorize, err) } - if username == "" || password == "" { - return authn, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", s.headerAuthorize, username, err) - } + authn.Header.Authorization = authz var ( - valid bool - details *authentication.UserDetails + username, clientID string + + ccs bool + level authentication.Level ) - if valid, err = ctx.Providers.UserProvider.CheckUserPassword(username, password); err != nil { - return authn, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", s.headerAuthorize, username, err) + scheme := authn.Header.Authorization.Scheme() + + if !s.schemes.Has(scheme) { + return authn, fmt.Errorf("invalid scheme: scheme with name '%s' isn't available on this endpoint", scheme.String()) } - if !valid { - return authn, fmt.Errorf("validated parsed credentials of %s header but they are not valid for user '%s': %w", s.headerAuthorize, username, err) + switch scheme { + case model.AuthorizationSchemeBasic: + username, level, err = s.handleGetBasic(ctx, authn, object) + case model.AuthorizationSchemeBearer: + username, clientID, ccs, level, err = handleVerifyGETAuthorizationBearer(ctx, authn, object) + default: + err = fmt.Errorf("failed to parse content of %s header: the scheme '%s' is not known", s.headerAuthorize, authn.Header.Authorization.SchemeRaw()) } - if details, err = ctx.Providers.UserProvider.GetDetails(username); err != nil { - if errors.Is(err, authentication.ErrUserNotFound) { - ctx.Logger.WithField("username", username).Error("Error occurred while attempting to get user details for user: the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login") + if err != nil { + return authn, fmt.Errorf("failed to validate %s header with %s scheme: %w", s.headerAuthorize, scheme, err) + } - return authn, err + switch { + case ccs: + if len(clientID) == 0 { + return authn, fmt.Errorf("failed to determine client id from the %s header", s.headerAuthorize) } - return authn, fmt.Errorf("unable to retrieve details for user '%s': %w", username, err) + authn.ClientID = clientID + case len(username) == 0: + return authn, fmt.Errorf("failed to determine username from the %s header", s.headerAuthorize) + default: + var details *authentication.UserDetails + + if details, err = ctx.Providers.UserProvider.GetDetails(username); err != nil { + if errors.Is(err, authentication.ErrUserNotFound) { + ctx.Logger.WithField("username", username).Error("Error occurred while attempting to get user details for user: the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login") + + return authn, err + } + + return authn, fmt.Errorf("unable to retrieve details for user '%s': %w", username, err) + } + + authn.Username = friendlyUsername(details.Username) + authn.Details = *details } - authn.Username = friendlyUsername(details.Username) - authn.Details = *details - authn.Level = authentication.OneFactor + authn.Level = level return authn, nil } +func (s *HeaderAuthnStrategy) handleGetBasic(ctx *middlewares.AutheliaCtx, authn *Authn, _ *authorization.Object) (username string, level authentication.Level, err error) { + var ( + valid bool + ) + + if valid, err = ctx.Providers.UserProvider.CheckUserPassword(authn.Header.Authorization.Basic()); err != nil { + return "", authentication.NotAuthenticated, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", s.headerAuthorize, authn.Header.Authorization.BasicUsername(), err) + } + + if !valid { + return "", authentication.NotAuthenticated, fmt.Errorf("validated parsed credentials of %s header but they are not valid for user '%s': %w", s.headerAuthorize, authn.Header.Authorization.BasicUsername(), err) + } + + return authn.Header.Authorization.BasicUsername(), authentication.OneFactor, nil +} + // CanHandleUnauthorized returns true if this AuthnStrategy should handle Unauthorized requests. func (s *HeaderAuthnStrategy) CanHandleUnauthorized() (handle bool) { return s.handleAuthenticate } // HandleUnauthorized is the Unauthorized handler for the header AuthnStrategy. -func (s *HeaderAuthnStrategy) HandleUnauthorized(ctx *middlewares.AutheliaCtx, _ *Authn, _ *url.URL) { +func (s *HeaderAuthnStrategy) HandleUnauthorized(ctx *middlewares.AutheliaCtx, authn *Authn, _ *url.URL) { ctx.Logger.Debugf("Responding %d %s", s.statusAuthenticate, s.headerAuthenticate) ctx.ReplyStatusCode(s.statusAuthenticate) - if s.headerAuthenticate != nil { + if authn.Header.Authorization != nil && authn.Header.Authorization.Scheme() == model.AuthorizationSchemeBearer && authn.Header.Error != nil { + ctx.Response.Header.SetBytesK(s.headerAuthenticate, fmt.Sprintf(`Bearer %s`, oidc.RFC6750Header(authn.Header.Realm, authn.Header.Scope, authn.Header.Error))) + } else if s.headerAuthenticate != nil { ctx.Response.Header.SetBytesKV(s.headerAuthenticate, headerValueAuthenticateBasic) } } @@ -226,13 +277,13 @@ func (s *HeaderAuthnStrategy) HandleUnauthorized(ctx *middlewares.AutheliaCtx, _ type HeaderLegacyAuthnStrategy struct{} // Get returns the Authn information for this AuthnStrategy. -func (s *HeaderLegacyAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, _ *session.Session) (authn Authn, err error) { +func (s *HeaderLegacyAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, _ *session.Session, _ *authorization.Object) (authn *Authn, err error) { var ( username, password string value, header []byte ) - authn = Authn{ + authn = &Authn{ Level: authentication.NotAuthenticated, Username: anonymous, } @@ -402,6 +453,95 @@ func handleVerifyGETAuthnCookieValidateRefresh(ctx *middlewares.AutheliaCtx, use return false } +func handleVerifyGETAuthorizationBearer(ctx *middlewares.AutheliaCtx, authn *Authn, object *authorization.Object) (username, clientID string, ccs bool, level authentication.Level, err error) { + if ctx.Providers.OpenIDConnect == nil || ctx.Configuration.IdentityProviders.OIDC == nil || !ctx.Configuration.IdentityProviders.OIDC.Discovery.BearerAuthorization { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("authorization bearer scheme requires an OpenID Connect 1.0 configuration but it's absent") + } + + if !ctx.Configuration.IdentityProviders.OIDC.Discovery.BearerAuthorization { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("authorization bearer scheme requires an OAuth 2.0 or OpenID Connect 1.0 client to be registered with the '%s' scope but there are none", oidc.ScopeAutheliaBearerAuthz) + } + + return handleVerifyGETAuthorizationBearerIntrospection(ctx, ctx.Providers.OpenIDConnect, authn, object) +} + +type AuthzBearerIntrospectionProvider interface { + GetFullClient(ctx context.Context, id string) (client oidc.Client, err error) + GetAudienceStrategy(ctx context.Context) (strategy fosite.AudienceMatchingStrategy) + IntrospectToken(ctx context.Context, token string, tokenUse fosite.TokenUse, session fosite.Session, scope ...string) (fosite.TokenUse, fosite.AccessRequester, error) +} + +func handleVerifyGETAuthorizationBearerIntrospection(ctx context.Context, provider AuthzBearerIntrospectionProvider, authn *Authn, object *authorization.Object) (username, clientID string, ccs bool, level authentication.Level, err error) { + var ( + use fosite.TokenUse + requester fosite.AccessRequester + ) + + authn.Header.Error = &fosite.RFC6749Error{ + ErrorField: "invalid_token", + DescriptionField: "The access token is expired, revoked, malformed, or invalid for other reasons. The client can obtain a new access token and try again.", + } + + if use, requester, err = provider.IntrospectToken(ctx, authn.Header.Authorization.Value(), fosite.AccessToken, oidc.NewSession(), oidc.ScopeAutheliaBearerAuthz); err != nil { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("error performing token introspection: %w", err) + } + + if use != fosite.AccessToken { + authn.Header.Error = fosite.ErrInvalidRequest + + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("token is not an access token") + } + + audience := []string{object.URL.String()} + strategy := provider.GetAudienceStrategy(ctx) + + if err = strategy(requester.GetGrantedAudience(), audience); err != nil { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("token does not contain a valid audience for the url '%s' with the error: %w", audience[0], err) + } + + fsession := requester.GetSession() + + var ( + client oidc.Client + osession *oidc.Session + ok bool + ) + + if osession, ok = fsession.(*oidc.Session); !ok { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("introspection returned an invalid session type") + } + + if client, err = provider.GetFullClient(ctx, osession.ClientID); err != nil || client == nil { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("client id '%s' is not registered", osession.ClientID) + } + + if !client.GetScopes().Has(oidc.ScopeAutheliaBearerAuthz) { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("client id '%s' is registered but does not permit the '%s' scope", osession.ClientID, oidc.ScopeAutheliaBearerAuthz) + } + + if err = strategy(client.GetAudience(), audience); err != nil { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("client id '%s' is registered but does not permit an audience for the url '%s' with the error: %w", osession.ClientID, audience[0], err) + } + + if osession.DefaultSession == nil || osession.DefaultSession.Claims == nil { + return "", "", false, authentication.NotAuthenticated, fmt.Errorf("introspection returned a session missing required values") + } + + authn.Header.Error = nil + + if osession.ClientCredentials { + return "", osession.ClientID, true, authentication.OneFactor, nil + } + + if oidc.NewAuthenticationMethodsReferencesFromClaim(osession.DefaultSession.Claims.AuthenticationMethodsReferences).MultiFactorAuthentication() { + level = authentication.TwoFactor + } else { + level = authentication.OneFactor + } + + return osession.Username, "", false, level, nil +} + func headerAuthorizationParse(value []byte) (username, password string, err error) { if bytes.Equal(value, qryValueEmpty) { return "", "", fmt.Errorf("header is malformed: empty value") diff --git a/internal/handlers/handler_authz_builder.go b/internal/handlers/handler_authz_builder.go index 43be45c0d..c82b52c3c 100644 --- a/internal/handlers/handler_authz_builder.go +++ b/internal/handlers/handler_authz_builder.go @@ -4,6 +4,7 @@ import ( "github.com/valyala/fasthttp" "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/model" ) // NewAuthzBuilder creates a new AuthzBuilder. @@ -87,11 +88,11 @@ func (b *AuthzBuilder) WithEndpointConfig(config schema.ServerEndpointsAuthz) *A case AuthnStrategyCookieSession: b.strategies = append(b.strategies, NewCookieSessionAuthnStrategy(b.config.RefreshInterval)) case AuthnStrategyHeaderAuthorization: - b.strategies = append(b.strategies, NewHeaderAuthorizationAuthnStrategy()) + b.strategies = append(b.strategies, NewHeaderAuthorizationAuthnStrategy(strategy.Schemes...)) case AuthnStrategyHeaderProxyAuthorization: - b.strategies = append(b.strategies, NewHeaderProxyAuthorizationAuthnStrategy()) + b.strategies = append(b.strategies, NewHeaderProxyAuthorizationAuthnStrategy(strategy.Schemes...)) case AuthnStrategyHeaderAuthRequestProxyAuthorization: - b.strategies = append(b.strategies, NewHeaderProxyAuthorizationAuthRequestAuthnStrategy()) + b.strategies = append(b.strategies, NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(strategy.Schemes...)) case AuthnStrategyHeaderLegacy: b.strategies = append(b.strategies, NewHeaderLegacyAuthnStrategy()) } @@ -116,9 +117,9 @@ func (b *AuthzBuilder) Build() (authz *Authz) { case AuthzImplLegacy: authz.strategies = []AuthnStrategy{NewHeaderLegacyAuthnStrategy(), NewCookieSessionAuthnStrategy(b.config.RefreshInterval)} case AuthzImplAuthRequest: - authz.strategies = []AuthnStrategy{NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), NewCookieSessionAuthnStrategy(b.config.RefreshInterval)} + authz.strategies = []AuthnStrategy{NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(model.AuthorizationSchemeBasic.String()), NewCookieSessionAuthnStrategy(b.config.RefreshInterval)} default: - authz.strategies = []AuthnStrategy{NewHeaderProxyAuthorizationAuthnStrategy(), NewCookieSessionAuthnStrategy(b.config.RefreshInterval)} + authz.strategies = []AuthnStrategy{NewHeaderProxyAuthorizationAuthnStrategy(model.AuthorizationSchemeBasic.String()), NewCookieSessionAuthnStrategy(b.config.RefreshInterval)} } } diff --git a/internal/handlers/handler_authz_test.go b/internal/handlers/handler_authz_test.go index 559a5cb57..dbba67e46 100644 --- a/internal/handlers/handler_authz_test.go +++ b/internal/handlers/handler_authz_test.go @@ -493,8 +493,8 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomainWithAuthorizationHead builder := NewAuthzBuilder().WithImplementationLegacy() builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(), - NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), + NewHeaderAuthorizationAuthnStrategy("basic"), + NewHeaderProxyAuthorizationAuthRequestAuthnStrategy("basic"), NewCookieSessionAuthnStrategy(builder.config.RefreshInterval), ) @@ -540,8 +540,8 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithoutHeaderNoCookie() { builder := NewAuthzBuilder().WithImplementationLegacy() builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(), - NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), + NewHeaderAuthorizationAuthnStrategy("basic"), + NewHeaderProxyAuthorizationAuthRequestAuthnStrategy("basic"), ) authz := builder.Build() @@ -573,8 +573,8 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithEmptyAuthorizationHeader() { builder := NewAuthzBuilder().WithImplementationLegacy() builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(), - NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), + NewHeaderAuthorizationAuthnStrategy("basic"), + NewHeaderProxyAuthorizationAuthRequestAuthnStrategy("basic"), ) authz := builder.Build() @@ -608,8 +608,8 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithAuthorizationHeaderInvalidPassword builder := NewAuthzBuilder().WithImplementationLegacy() builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(), - NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), + NewHeaderAuthorizationAuthnStrategy("basic"), + NewHeaderProxyAuthorizationAuthRequestAuthnStrategy("basic"), ) authz := builder.Build() @@ -645,7 +645,7 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithIncorrectAuthHeader() { // TestSho builder := s.Builder() builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(), + NewHeaderAuthorizationAuthnStrategy("basic"), ) authz := builder.Build() diff --git a/internal/handlers/handler_authz_types.go b/internal/handlers/handler_authz_types.go index 6a2f5d370..ec6192a87 100644 --- a/internal/handlers/handler_authz_types.go +++ b/internal/handlers/handler_authz_types.go @@ -3,10 +3,13 @@ package handlers import ( "net/url" + "github.com/ory/fosite" + "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/session" ) @@ -66,11 +69,21 @@ const ( type Authn struct { Username string Method string + ClientID string Details authentication.UserDetails Level authentication.Level Object authorization.Object Type AuthnType + + Header HeaderAuthorization +} + +type HeaderAuthorization struct { + Authorization *model.Authorization + Realm string + Scope string + Error *fosite.RFC6749Error } // AuthzConfig represents the configuration elements of the Authz type. @@ -91,7 +104,7 @@ type AuthzBuilder struct { // AuthnStrategy is a strategy used for Authz authentication. type AuthnStrategy interface { - Get(ctx *middlewares.AutheliaCtx, provider *session.Session) (authn Authn, err error) + Get(ctx *middlewares.AutheliaCtx, provider *session.Session, object *authorization.Object) (authn *Authn, err error) CanHandleUnauthorized() (handle bool) HandleUnauthorized(ctx *middlewares.AutheliaCtx, authn *Authn, redirectionURL *url.URL) } diff --git a/internal/handlers/handler_oidc_token.go b/internal/handlers/handler_oidc_token.go index eadd16839..cb8cc854f 100644 --- a/internal/handlers/handler_oidc_token.go +++ b/internal/handlers/handler_oidc_token.go @@ -34,10 +34,20 @@ func OpenIDConnectTokenPOST(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter ctx.Logger.Debugf("Access Request with id '%s' on client with id '%s' is being processed", requester.GetID(), client.GetID()) if requester.GetGrantTypes().ExactOne(oidc.GrantTypeClientCredentials) { - if err = oidc.PopulateClientCredentialsFlowSessionWithAccessRequest(ctx, requester, session, ctx.Providers.OpenIDConnect.KeyManager.GetKeyID); err != nil { + if err = oidc.PopulateClientCredentialsFlowSessionWithAccessRequest(ctx, client, session); err != nil { ctx.Logger.Errorf("Access Response for Request with id '%s' failed to be created with error: %s", requester.GetID(), oidc.ErrorToDebugRFC6749Error(err)) ctx.Providers.OpenIDConnect.WriteAccessError(ctx, rw, requester, err) + + return + } + + if err = oidc.PopulateClientCredentialsFlowRequester(ctx, ctx.Providers.OpenIDConnect, client, requester); err != nil { + ctx.Logger.Errorf("Access Response for Request with id '%s' failed to be created with error: %s", requester.GetID(), oidc.ErrorToDebugRFC6749Error(err)) + + ctx.Providers.OpenIDConnect.WriteAccessError(ctx, rw, requester, err) + + return } } diff --git a/internal/handlers/handler_oidc_userinfo.go b/internal/handlers/handler_oidc_userinfo.go index 34ca1291a..bd83bc6e2 100644 --- a/internal/handlers/handler_oidc_userinfo.go +++ b/internal/handlers/handler_oidc_userinfo.go @@ -44,7 +44,7 @@ func OpenIDConnectUserinfo(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, ctx.Logger.Errorf("UserInfo Request with id '%s' failed with error: %s", requestID, oidc.ErrorToDebugRFC6749Error(err)) if rfc := fosite.ErrorToRFC6749Error(err); rfc.StatusCode() == http.StatusUnauthorized { - rw.Header().Set(fasthttp.HeaderWWWAuthenticate, fmt.Sprintf(`Bearer error="%s",error_description="%s"`, rfc.ErrorField, rfc.GetDescription())) + rw.Header().Set(fasthttp.HeaderWWWAuthenticate, fmt.Sprintf(`Bearer %s`, oidc.RFC6750Header("", "", rfc))) } ctx.Providers.OpenIDConnect.WriteError(rw, req, err) diff --git a/internal/model/authorization.go b/internal/model/authorization.go new file mode 100644 index 000000000..7acc2bede --- /dev/null +++ b/internal/model/authorization.go @@ -0,0 +1,246 @@ +package model + +import ( + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +func NewAuthorization() *Authorization { + return &Authorization{} +} + +type Authorization struct { + parsed bool + scheme AuthorizationScheme + rawscheme string + value string + username string + password string +} + +func (a *Authorization) SchemeRaw() string { + return a.rawscheme +} + +func (a *Authorization) Scheme() AuthorizationScheme { + return a.scheme +} + +func (a *Authorization) Value() string { + return a.value +} + +func (a *Authorization) EncodeHeader() string { + if !a.parsed { + return "" + } + + switch a.scheme { + case AuthorizationSchemeNone: + return "" + case AuthorizationSchemeBasic, AuthorizationSchemeBearer: + return fmt.Sprintf("%s %s", cases.Title(language.English).String(a.scheme.String()), a.value) + default: + return "" + } +} + +func (a *Authorization) Basic() (username, password string) { + if !a.parsed { + return "", "" + } + + switch a.scheme { + case AuthorizationSchemeBasic: + return a.username, a.password + default: + return "", "" + } +} + +func (a *Authorization) BasicUsername() (username string) { + if !a.parsed { + return "" + } + + switch a.scheme { + case AuthorizationSchemeBasic: + return a.username + default: + return "" + } +} + +func (a *Authorization) ParseBasic(username, password string) (err error) { + if a.parsed { + return fmt.Errorf("invalid state: this scheme has already performed a parse action") + } + + switch { + case len(username) == 0: + return fmt.Errorf("invalid value: username must not be empty") + case strings.Contains(username, ":"): + return fmt.Errorf("invalid value: username must not contain the ':' character") + case len(password) == 0: + return fmt.Errorf("invalid value: password must not be empty") + } + + a.parsed = true + + a.username, a.password, a.scheme, a.rawscheme = username, password, AuthorizationSchemeBasic, AuthorizationSchemeBasic.String() + + a.value = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + + return nil +} + +func (a *Authorization) ParseBearer(bearer string) (err error) { + if a.parsed { + return fmt.Errorf("invalid state: this scheme has already performed a parse action") + } + + if err = a.validateSchemeBearerValue(bearer); err != nil { + return err + } + + a.parsed = true + + a.value, a.scheme, a.rawscheme = bearer, AuthorizationSchemeBearer, AuthorizationSchemeBearer.String() + + return nil +} + +func (a *Authorization) Parse(raw string) (err error) { + if a.parsed { + return fmt.Errorf("invalid state: this scheme has already performed a parse action") + } + + if len(raw) == 0 { + return fmt.Errorf("invalid value: the value provided to be parsed was empty") + } + + scheme, value, found := strings.Cut(raw, " ") + + if !found { + return fmt.Errorf("invalid scheme: the scheme is missing") + } + + switch s := strings.ToLower(scheme); s { + case AuthorizationSchemeBasic.String(): + if err = a.parseSchemeBasic(value); err != nil { + return err + } + + a.scheme = AuthorizationSchemeBasic + case AuthorizationSchemeBearer.String(): + if err = a.parseSchemeBearer(value); err != nil { + return err + } + + a.scheme = AuthorizationSchemeBearer + default: + return fmt.Errorf("invalid scheme: scheme with name '%s' is unknown", s) + } + + a.parsed = true + + a.rawscheme = scheme + a.value = value + + return nil +} + +func (a *Authorization) parseSchemeBasic(value string) (err error) { + var decoded []byte + + if decoded, err = base64.StdEncoding.DecodeString(value); err != nil { + return fmt.Errorf("invalid value: failed to parse base64 basic scheme value: %w", err) + } + + username, password, found := strings.Cut(string(decoded), ":") + + if !found { + return fmt.Errorf("invalid value: failed to find the username password separator in the decoded basic scheme value") + } + + if len(username) == 0 { + return fmt.Errorf("invalid value: failed to find the username in the decoded basic value as it was empty") + } + + if len(password) == 0 { + return fmt.Errorf("invalid value: failed to find the password in the decoded basic value as it was empty") + } + + a.username, a.password = username, password + + return nil +} + +func (a *Authorization) parseSchemeBearer(value string) (err error) { + return a.validateSchemeBearerValue(value) +} + +func (a *Authorization) validateSchemeBearerValue(bearer string) (err error) { + switch { + case len(bearer) == 0: + return fmt.Errorf("invalid value: bearer scheme value must not be empty") + case !reToken64.MatchString(bearer): + return fmt.Errorf("invalid value: bearer scheme value must only contain characters noted in RFC6750 2.1") + default: + return nil + } +} + +func (a *Authorization) ParseBytes(raw []byte) (err error) { + return a.Parse(string(raw)) +} + +func NewAuthorizationSchemes(schemes ...string) AuthorizationSchemes { + var s AuthorizationSchemes + + for _, raw := range schemes { + switch strings.ToLower(raw) { + case AuthorizationSchemeBasic.String(): + s = append(s, AuthorizationSchemeBasic) + case AuthorizationSchemeBearer.String(): + s = append(s, AuthorizationSchemeBearer) + } + } + + return s +} + +type AuthorizationSchemes []AuthorizationScheme + +func (s AuthorizationSchemes) Has(scheme AuthorizationScheme) bool { + for _, value := range s { + if scheme == value { + return true + } + } + + return false +} + +type AuthorizationScheme int + +func (s AuthorizationScheme) String() string { + switch s { + case AuthorizationSchemeBasic: + return "basic" + case AuthorizationSchemeBearer: + return "bearer" + default: + return "" + } +} + +const ( + AuthorizationSchemeNone AuthorizationScheme = iota + AuthorizationSchemeBasic + AuthorizationSchemeBearer +) diff --git a/internal/model/authorization_test.go b/internal/model/authorization_test.go new file mode 100644 index 000000000..40e579d1e --- /dev/null +++ b/internal/model/authorization_test.go @@ -0,0 +1,393 @@ +package model + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAuthorization_Parse(t *testing.T) { + testCases := []struct { + name string + have string + expected string + expectedsr string + scheme AuthorizationScheme + value string + username string + password string + err string + }{ + { + "ShouldParseBearer", + "Bearer abc123", + "Bearer abc123", + "Bearer", + AuthorizationSchemeBearer, + "abc123", + "", + "", + "", + }, + { + "ShouldParseBearerAnyCase", + "BeareR abc123", + "Bearer abc123", + "BeareR", + AuthorizationSchemeBearer, + "abc123", + "", + "", + "", + }, + { + "ShouldFailParseBearerNoScheme", + "Bearer", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid scheme: the scheme is missing", + }, + { + "ShouldFailParseBearerEmpty", + "Bearer ", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid value: bearer scheme value must not be empty", + }, + { + "ShouldFailParseBearerWithBadValues", + "Bearer !(@)#&!@$&(^T)*@#&^!", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid value: bearer scheme value must only contain characters noted in RFC6750 2.1", + }, + { + "ShouldParseBasic", + "Basic YWJjOjEyMw==", + "Basic YWJjOjEyMw==", + "Basic", + AuthorizationSchemeBasic, + "YWJjOjEyMw==", + "abc", + "123", + "", + }, + { + "ShouldFailParseBasicNoUsername", + "Basic OjEyMw==", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid value: failed to find the username in the decoded basic value as it was empty", + }, + { + "ShouldFailParseBasicNoPassword", + "Basic YWJjOg==", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid value: failed to find the password in the decoded basic value as it was empty", + }, + { + "ShouldFailParseBasicNoSep", + "Basic YWJjMTIz", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid value: failed to find the username password separator in the decoded basic scheme value", + }, + { + "ShouldFailParseBasicBadBase64", + "Basic ===YWJjMTIz", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid value: failed to parse base64 basic scheme value: illegal base64 data at input byte 0", + }, + { + "ShouldFailParseBadScheme", + "Baser YWJjOjEyMw==", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid scheme: scheme with name 'baser' is unknown", + }, + { + "ShouldFailParseEmpty", + "", + "", + "", + AuthorizationSchemeNone, + "", + "", + "", + "invalid value: the value provided to be parsed was empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + authz := NewAuthorization() + + err := authz.Parse(tc.have) + + if len(tc.err) == 0 { + assert.NoError(t, err) + assert.Equal(t, tc.scheme, authz.Scheme()) + assert.Equal(t, tc.expectedsr, authz.SchemeRaw()) + assert.Equal(t, tc.password, authz.password) + assert.Equal(t, tc.username, authz.username) + assert.Equal(t, tc.expected, authz.EncodeHeader()) + + assert.EqualError(t, authz.Parse(tc.have), "invalid state: this scheme has already performed a parse action") + + bauthz := NewAuthorization() + + assert.NoError(t, bauthz.ParseBytes([]byte(tc.have))) + } else { + assert.EqualError(t, err, tc.err) + } + }) + } +} + +func TestAuthorization_ParsBasic(t *testing.T) { + testCases := []struct { + name string + username string + password string + expected string + err string + }{ + { + "ShouldParseGoodValues", + "abc", + "123", + "YWJjOjEyMw==", + "", + }, + { + "ShouldFailUsernameWithColon", + "abc:abc", + "123", + "", + "invalid value: username must not contain the ':' character", + }, + { + "ShouldFailUsernameEmpty", + "", + "123", + "", + "invalid value: username must not be empty", + }, + { + "ShouldFailPasswordEmpty", + "abc", + "", + "", + "invalid value: password must not be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + authz := NewAuthorization() + + err := authz.ParseBasic(tc.username, tc.password) + + if len(tc.err) == 0 { + assert.NoError(t, err) + + assert.Equal(t, AuthorizationSchemeBasic, authz.Scheme()) + assert.Equal(t, fmt.Sprintf("Basic %s", tc.expected), authz.EncodeHeader()) + assert.Equal(t, tc.expected, authz.value) + assert.Equal(t, tc.expected, authz.Value()) + assert.Equal(t, tc.username, authz.username) + assert.Equal(t, tc.username, authz.BasicUsername()) + assert.Equal(t, tc.password, authz.password) + + username, password := authz.Basic() + + assert.Equal(t, tc.username, username) + assert.Equal(t, tc.password, password) + + assert.EqualError(t, authz.ParseBasic(tc.username, tc.password), "invalid state: this scheme has already performed a parse action") + } else { + assert.EqualError(t, err, tc.err) + } + }) + } +} + +func TestAuthorization_ParsBearer(t *testing.T) { + testCases := []struct { + name string + bearer string + expected string + err string + }{ + { + "ShouldParseGoodValues", + "abc", + "abc", + "", + }, + { + "ShouldFailParseBadBearerValue", + "abc!(*@^&(!@*^$", + "", + "invalid value: bearer scheme value must only contain characters noted in RFC6750 2.1", + }, + { + "ShouldFailParseEmptyBearerValue", + "", + "", + "invalid value: bearer scheme value must not be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + authz := NewAuthorization() + + err := authz.ParseBearer(tc.bearer) + + if len(tc.err) == 0 { + assert.NoError(t, err) + + assert.Equal(t, AuthorizationSchemeBearer, authz.Scheme()) + assert.Equal(t, fmt.Sprintf("Bearer %s", tc.expected), authz.EncodeHeader()) + assert.Equal(t, tc.expected, authz.value) + assert.Equal(t, tc.expected, authz.Value()) + + username, password := authz.Basic() + assert.Equal(t, "", authz.BasicUsername()) + assert.Equal(t, "", username) + assert.Equal(t, "", password) + + assert.EqualError(t, authz.ParseBearer(tc.bearer), "invalid state: this scheme has already performed a parse action") + } else { + assert.EqualError(t, err, tc.err) + } + }) + } +} + +func TestAuthorization_NotParsed(t *testing.T) { + authz := NewAuthorization() + + assert.Equal(t, "", authz.EncodeHeader()) + assert.Equal(t, "", authz.BasicUsername()) + + username, password := authz.Basic() + assert.Equal(t, "", username) + assert.Equal(t, "", password) +} + +func TestAuthorization_SchemeNone(t *testing.T) { + authz := NewAuthorization() + authz.parsed = true + + assert.Equal(t, "", authz.EncodeHeader()) + assert.Equal(t, "", authz.BasicUsername()) + + username, password := authz.Basic() + assert.Equal(t, "", username) + assert.Equal(t, "", password) +} + +func TestAuthorization_Misc(t *testing.T) { + authz := NewAuthorization() + authz.parsed = true + authz.scheme = -1 + + assert.Equal(t, "", authz.EncodeHeader()) + + assert.Equal(t, "", AuthorizationScheme(-1).String()) +} + +func TestNewAuthorizationSchemes(t *testing.T) { + testCases := []struct { + name string + have []string + expected AuthorizationSchemes + expectedf func(t *testing.T, schemes AuthorizationSchemes) + }{ + { + "ShouldParseEmpty", + nil, + nil, + nil, + }, + { + "ShouldParseBasic", + []string{"BaSiC"}, + AuthorizationSchemes{AuthorizationSchemeBasic}, + func(t *testing.T, schemes AuthorizationSchemes) { + assert.False(t, schemes.Has(AuthorizationSchemeNone)) + assert.True(t, schemes.Has(AuthorizationSchemeBasic)) + assert.False(t, schemes.Has(AuthorizationSchemeBearer)) + }, + }, + { + "ShouldParseBearer", + []string{"Bearer"}, + AuthorizationSchemes{AuthorizationSchemeBearer}, + func(t *testing.T, schemes AuthorizationSchemes) { + assert.False(t, schemes.Has(AuthorizationSchemeNone)) + assert.False(t, schemes.Has(AuthorizationSchemeBasic)) + assert.True(t, schemes.Has(AuthorizationSchemeBearer)) + }, + }, + { + "ShouldParseBoth", + []string{"Bearer", "Basic"}, + AuthorizationSchemes{AuthorizationSchemeBearer, AuthorizationSchemeBasic}, + func(t *testing.T, schemes AuthorizationSchemes) { + assert.False(t, schemes.Has(AuthorizationSchemeNone)) + assert.True(t, schemes.Has(AuthorizationSchemeBasic)) + assert.True(t, schemes.Has(AuthorizationSchemeBearer)) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := NewAuthorizationSchemes(tc.have...) + + assert.Equal(t, tc.expected, actual) + + if tc.expectedf != nil { + tc.expectedf(t, actual) + } + }) + } +} diff --git a/internal/model/const.go b/internal/model/const.go index 3df982a37..9ba272b87 100644 --- a/internal/model/const.go +++ b/internal/model/const.go @@ -22,7 +22,10 @@ const ( SecondFactorMethodDuo = "mobile_push" ) -var reSemanticVersion = regexp.MustCompile(`^v?(?P<Major>0|[1-9]\d*)\.(?P<Minor>0|[1-9]\d*)\.(?P<Patch>0|[1-9]\d*)(?:-(?P<PreRelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<Metadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) +var ( + reSemanticVersion = regexp.MustCompile(`^v?(?P<Major>0|[1-9]\d*)\.(?P<Minor>0|[1-9]\d*)\.(?P<Patch>0|[1-9]\d*)(?:-(?P<PreRelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<Metadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) + reToken64 = regexp.MustCompile(`^[a-zA-Z0-9_.~+/=-]+$`) +) const ( semverRegexpGroupPreRelease = "PreRelease" diff --git a/internal/model/oidc_test.go b/internal/model/oidc_test.go index fde5d7f2c..cfe94f564 100644 --- a/internal/model/oidc_test.go +++ b/internal/model/oidc_test.go @@ -334,7 +334,7 @@ func TestNewOAuth2PARContext(t *testing.T) { ResponseMode: oidc.ResponseModeQuery, DefaultResponseMode: oidc.ResponseModeFragment, Form: "redirect_uri=https%3A%2F%2Fexample.com", - Session: []byte(`{"id_token":null,"challenge_id":null,"kid":"","client_id":"","exclude_nbf_claim":false,"allowed_top_level_claims":null,"extra":null}`), + Session: []byte(`{"id_token":null,"challenge_id":null,"kid":"","client_id":"","client_credentials":false,"exclude_nbf_claim":false,"allowed_top_level_claims":null,"extra":null}`), }, "", }, diff --git a/internal/oidc/amr.go b/internal/oidc/amr.go index cd2d8d6b0..8c13c5b20 100644 --- a/internal/oidc/amr.go +++ b/internal/oidc/amr.go @@ -1,5 +1,24 @@ package oidc +func NewAuthenticationMethodsReferencesFromClaim(claim []string) (amr AuthenticationMethodsReferences) { + for _, ref := range claim { + switch ref { + case AMRPasswordBasedAuthentication: + amr.UsernameAndPassword = true + case AMROneTimePassword: + amr.TOTP = true + case AMRShortMessageService: + amr.Duo = true + case AMRHardwareSecuredKey: + amr.WebAuthn = true + case AMRUserPresence: + amr.WebAuthnUserVerified = true + } + } + + return amr +} + // AuthenticationMethodsReferences holds AMR information. type AuthenticationMethodsReferences struct { UsernameAndPassword bool diff --git a/internal/oidc/const.go b/internal/oidc/const.go index ed5e7837e..fc0338207 100644 --- a/internal/oidc/const.go +++ b/internal/oidc/const.go @@ -12,6 +12,8 @@ const ( ScopeProfile = "profile" ScopeEmail = "email" ScopeGroups = "groups" + + ScopeAutheliaBearerAuthz = "authelia.bearer.authz" ) // Registered Claim strings. See https://www.iana.org/assignments/jwt/jwt.xhtml. @@ -353,3 +355,10 @@ const ( const ( durationZero = time.Duration(0) ) + +const ( + fieldRFC6750Error = "error" + fieldRFC6750ErrorDescription = "error_description" + fieldRFC6750Realm = "realm" + fieldRFC6750Scope = valueScope +) diff --git a/internal/oidc/flow_client_credentials.go b/internal/oidc/flow_client_credentials.go index deb57a487..118f9496e 100644 --- a/internal/oidc/flow_client_credentials.go +++ b/internal/oidc/flow_client_credentials.go @@ -2,6 +2,7 @@ package oidc import ( "context" + "net/url" "time" "github.com/ory/fosite" @@ -92,3 +93,78 @@ func (c *ClientCredentialsGrantHandler) CanHandleTokenEndpointRequest(ctx contex var ( _ fosite.TokenEndpointHandler = (*ClientCredentialsGrantHandler)(nil) ) + +// PopulateClientCredentialsFlowSessionWithAccessRequest is used to configure a session when performing a client credentials grant. +func PopulateClientCredentialsFlowSessionWithAccessRequest(ctx Context, client fosite.Client, session *Session) (err error) { + var ( + issuer *url.URL + ) + + if issuer, err = ctx.IssuerURL(); err != nil { + return fosite.ErrServerError.WithWrap(err).WithDebugf("Failed to determine the issuer with error: %s.", err.Error()) + } + + if client == nil { + return fosite.ErrServerError.WithDebug("Failed to get the client for the request.") + } + + session.Subject = "" + session.Claims.Subject = client.GetID() + session.ClientID = client.GetID() + session.DefaultSession.Claims.Issuer = issuer.String() + session.DefaultSession.Claims.IssuedAt = ctx.GetClock().Now().UTC() + session.DefaultSession.Claims.RequestedAt = ctx.GetClock().Now().UTC() + session.ClientCredentials = true + + return nil +} + +// PopulateClientCredentialsFlowRequester is used to grant the authorized scopes and audiences when performing a client +// credentials grant. +func PopulateClientCredentialsFlowRequester(ctx Context, config fosite.Configurator, client fosite.Client, requester fosite.Requester) (err error) { + if client == nil || config == nil || requester == nil { + return fosite.ErrServerError.WithDebug("Failed to get the client, configuration, or requester for the request.") + } + + scopes := requester.GetRequestedScopes() + audience := requester.GetRequestedAudience() + + var authz, nauthz bool + + strategy := config.GetScopeStrategy(ctx) + + for _, scope := range scopes { + switch scope { + case ScopeOffline, ScopeOfflineAccess: + break + case ScopeAutheliaBearerAuthz: + authz = true + default: + nauthz = true + } + + if strategy(client.GetScopes(), scope) { + requester.GrantScope(scope) + } else { + return fosite.ErrInvalidScope.WithDebugf("The scope '%s' is not authorized on client with id '%s'.", scope, client.GetID()) + } + } + + if authz && nauthz { + return fosite.ErrInvalidScope.WithDebugf("The scope '%s' must only be requested by itself or with the '%s' scope, no other scopes are permitted.", ScopeAutheliaBearerAuthz, ScopeOfflineAccess) + } + + if authz && len(audience) == 0 { + return fosite.ErrInvalidRequest.WithDebugf("The scope '%s' requires the request also include an audience.", ScopeAutheliaBearerAuthz) + } + + if err = config.GetAudienceStrategy(ctx)(client.GetAudience(), audience); err != nil { + return err + } + + for _, aud := range audience { + requester.GrantAudience(aud) + } + + return nil +} diff --git a/internal/oidc/flow_client_credentials_test.go b/internal/oidc/flow_client_credentials_test.go index 0e1b0ef50..646263340 100644 --- a/internal/oidc/flow_client_credentials_test.go +++ b/internal/oidc/flow_client_credentials_test.go @@ -2,15 +2,20 @@ package oidc_test import ( "context" + "errors" "net/http" + "net/url" "testing" "time" "github.com/ory/fosite" "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + fjwt "github.com/ory/fosite/token/jwt" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" + "github.com/authelia/authelia/v4/internal/clock" "github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/oidc" ) @@ -258,3 +263,223 @@ func TestClientCredentialsGrantHandler_PopulateTokenEndpointResponse(t *testing. }) } } + +func TestPopulateClientCredentialsFlowSessionWithAccessRequest(t *testing.T) { + testCases := []struct { + name string + setup func(ctx oidc.Context) + ctx oidc.Context + client fosite.Client + have *oidc.Session + expected *oidc.Session + err string + }{ + { + "ShouldHandleIssuerError", + nil, + &TestContext{ + IssuerURLFunc: func() (issuerURL *url.URL, err error) { + return nil, errors.New("an error") + }, + }, + nil, + oidc.NewSession(), + nil, + "The authorization server encountered an unexpected condition that prevented it from fulfilling the request. Failed to determine the issuer with error: an error.", + }, + { + "ShouldHandleClientError", + nil, + &TestContext{ + IssuerURLFunc: func() (issuerURL *url.URL, err error) { + return &url.URL{Scheme: "https", Host: "example.com"}, nil + }, + }, + nil, + oidc.NewSession(), + nil, + "The authorization server encountered an unexpected condition that prevented it from fulfilling the request. Failed to get the client for the request.", + }, + { + "ShouldUpdateValues", + func(ctx oidc.Context) { + c := ctx.(*TestContext) + + c.Clock = clock.NewFixed(time.Unix(10000000000, 0)) + }, + &TestContext{ + IssuerURLFunc: func() (issuerURL *url.URL, err error) { + return &url.URL{Scheme: "https", Host: "example.com"}, nil + }, + }, + &oidc.BaseClient{ + ID: abc, + }, + oidc.NewSession(), + &oidc.Session{ + Extra: map[string]any{}, + DefaultSession: &openid.DefaultSession{ + Headers: &fjwt.Headers{ + Extra: map[string]any{}, + }, + Claims: &fjwt.IDTokenClaims{ + Issuer: "https://example.com", + IssuedAt: time.Unix(10000000000, 0).UTC(), + RequestedAt: time.Unix(10000000000, 0).UTC(), + Subject: abc, + Extra: map[string]any{}, + }, + }, + ClientID: abc, + ClientCredentials: true, + }, + "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.setup != nil { + tc.setup(tc.ctx) + } + + err := oidc.PopulateClientCredentialsFlowSessionWithAccessRequest(tc.ctx, tc.client, tc.have) + + assert.Equal(t, "", tc.have.GetSubject()) + + if len(tc.err) == 0 { + assert.NoError(t, err) + assert.EqualValues(t, tc.expected, tc.have) + } else { + assert.EqualError(t, oidc.ErrorToDebugRFC6749Error(err), tc.err) + } + }) + } +} + +func TestPopulateClientCredentialsFlowRequester(t *testing.T) { + testCases := []struct { + name string + setup func(ctx oidc.Context) + ctx oidc.Context + config fosite.Configurator + client fosite.Client + have *fosite.Request + expected *fosite.Request + err string + }{ + { + "ShouldHandleBasic", + nil, + &TestContext{}, + &oidc.Config{}, + &oidc.BaseClient{}, + &fosite.Request{}, + &fosite.Request{}, + "", + }, + { + "ShouldHandleNilErrorClient", + nil, + &TestContext{}, + &oidc.Config{}, + nil, + &fosite.Request{}, + &fosite.Request{}, + "The authorization server encountered an unexpected condition that prevented it from fulfilling the request. Failed to get the client, configuration, or requester for the request.", + }, + { + "ShouldHandleBadScopeCombinationAuthz", + nil, + &TestContext{}, + &oidc.Config{}, + &oidc.BaseClient{ID: "abc", Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOpenID}}, + &fosite.Request{RequestedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOpenID}}, + &fosite.Request{}, + "The requested scope is invalid, unknown, or malformed. The scope 'authelia.bearer.authz' must only be requested by itself or with the 'offline_access' scope, no other scopes are permitted.", + }, + { + "ShouldHandleScopeNotPermitted", + nil, + &TestContext{}, + &oidc.Config{}, + &oidc.BaseClient{ID: "abc", Scopes: []string{oidc.ScopeAutheliaBearerAuthz}}, + &fosite.Request{RequestedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}}, + &fosite.Request{}, + "The requested scope is invalid, unknown, or malformed. The scope 'offline_access' is not authorized on client with id 'abc'.", + }, + { + "ShouldHandleGoodScopesWithoutAudience", + nil, + &TestContext{}, + &oidc.Config{}, + &oidc.BaseClient{ID: "abc", Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}}, + &fosite.Request{RequestedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}}, + &fosite.Request{}, + "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Make sure that the various parameters are correct, be aware of case sensitivity and trim your parameters. Make sure that the client you are using has exactly whitelisted the redirect_uri you specified. The scope 'authelia.bearer.authz' requires the request also include an audience.", + }, + { + "ShouldHandleGoodScopesAndBadAudience", + nil, + &TestContext{}, + &oidc.Config{}, + &oidc.BaseClient{ID: "abc", Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}}, + &fosite.Request{RequestedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, RequestedAudience: fosite.Arguments{"https://example.com"}}, + &fosite.Request{}, + "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Requested audience 'https://example.com' has not been whitelisted by the OAuth 2.0 Client.", + }, + { + "ShouldHandleGoodScopesAndAudience", + nil, + &TestContext{}, + &oidc.Config{}, + &oidc.BaseClient{ID: "abc", Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, Audience: fosite.Arguments{"https://example.com"}}, + &fosite.Request{ + RequestedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + RequestedAudience: fosite.Arguments{"https://example.com"}, + }, + &fosite.Request{ + RequestedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + GrantedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + RequestedAudience: fosite.Arguments{"https://example.com"}, + GrantedAudience: fosite.Arguments{"https://example.com"}, + }, + "", + }, + { + "ShouldHandleGoodScopesAndAudienceSubSet", + nil, + &TestContext{}, + &oidc.Config{}, + &oidc.BaseClient{ID: "abc", Scopes: []string{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, Audience: fosite.Arguments{"https://example.com", "https://app.example.com"}}, + &fosite.Request{ + RequestedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + RequestedAudience: fosite.Arguments{"https://example.com"}, + }, + &fosite.Request{ + RequestedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + GrantedScope: fosite.Arguments{oidc.ScopeAutheliaBearerAuthz, oidc.ScopeOfflineAccess}, + RequestedAudience: fosite.Arguments{"https://example.com"}, + GrantedAudience: fosite.Arguments{"https://example.com"}, + }, + "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.setup != nil { + tc.setup(tc.ctx) + } + + err := oidc.PopulateClientCredentialsFlowRequester(tc.ctx, tc.config, tc.client, tc.have) + + if len(tc.err) == 0 { + assert.NoError(t, err) + assert.EqualValues(t, tc.expected, tc.have) + } else { + assert.EqualError(t, oidc.ErrorToDebugRFC6749Error(err), tc.err) + } + }) + } +} diff --git a/internal/oidc/flow_refresh.go b/internal/oidc/flow_refresh.go index b95e233bb..6d4769b82 100644 --- a/internal/oidc/flow_refresh.go +++ b/internal/oidc/flow_refresh.go @@ -92,7 +92,7 @@ func (c *RefreshTokenGrantHandler) HandleTokenEndpointRequest(ctx context.Contex include any scope not originally granted by the resource owner, and if omitted is treated as equal to the scope originally granted by the resource owner. - See https://www.rfc-editor.org/rfc/rfc6749#section-6 + See https://datatracker.ietf.org/doc/html/rfc6749#section-6 */ // Addresses point 1 of the text in RFC6749 Section 6. diff --git a/internal/oidc/session.go b/internal/oidc/session.go index 719d81c9b..7b283bfd7 100644 --- a/internal/oidc/session.go +++ b/internal/oidc/session.go @@ -1,7 +1,6 @@ package oidc import ( - "context" "net/url" "time" @@ -71,32 +70,6 @@ func NewSessionWithAuthorizeRequest(ctx Context, issuer *url.URL, kid, username return session } -// PopulateClientCredentialsFlowSessionWithAccessRequest is used to configure a session when performing a client credentials grant. -func PopulateClientCredentialsFlowSessionWithAccessRequest(ctx Context, request fosite.AccessRequester, session *Session, funcGetKID func(ctx context.Context, kid, alg string) string) (err error) { - var ( - issuer *url.URL - client Client - ok bool - ) - - if issuer, err = ctx.IssuerURL(); err != nil { - return fosite.ErrServerError.WithWrap(err).WithDebugf("Failed to determine the issuer with error: %s.", err.Error()) - } - - if client, ok = request.GetClient().(Client); !ok { - return fosite.ErrServerError.WithDebugf("Failed to get the client for the request.") - } - - session.Subject = "" - session.Claims.Subject = client.GetID() - session.ClientID = client.GetID() - session.DefaultSession.Claims.Issuer = issuer.String() - session.DefaultSession.Claims.IssuedAt = ctx.GetClock().Now().UTC() - session.DefaultSession.Claims.RequestedAt = ctx.GetClock().Now().UTC() - - return nil -} - // Session holds OpenID Connect 1.0 Session information. type Session struct { *openid.DefaultSession `json:"id_token"` @@ -104,6 +77,7 @@ type Session struct { 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"` diff --git a/internal/oidc/types_test.go b/internal/oidc/types_test.go index eeb26bc36..3662ac0e8 100644 --- a/internal/oidc/types_test.go +++ b/internal/oidc/types_test.go @@ -2,7 +2,6 @@ package oidc_test import ( "context" - "errors" "net/url" "testing" "time" @@ -10,8 +9,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" - fjwt "github.com/ory/fosite/token/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -108,102 +105,6 @@ func TestNewSessionWithAuthorizeRequest(t *testing.T) { assert.Nil(t, session.Claims.AuthenticationMethodsReferences) } -func TestPopulateClientCredentialsFlowSessionWithAccessRequest(t *testing.T) { - testCases := []struct { - name string - setup func(ctx oidc.Context) - ctx oidc.Context - request fosite.AccessRequester - have *oidc.Session - expected *oidc.Session - err string - }{ - { - "ShouldHandleIssuerError", - nil, - &TestContext{ - IssuerURLFunc: func() (issuerURL *url.URL, err error) { - return nil, errors.New("an error") - }, - }, - &fosite.AccessRequest{}, - oidc.NewSession(), - nil, - "The authorization server encountered an unexpected condition that prevented it from fulfilling the request. Failed to determine the issuer with error: an error.", - }, - { - "ShouldHandleClientError", - nil, - &TestContext{ - IssuerURLFunc: func() (issuerURL *url.URL, err error) { - return &url.URL{Scheme: "https", Host: "example.com"}, nil - }, - }, - &fosite.AccessRequest{}, - oidc.NewSession(), - nil, - "The authorization server encountered an unexpected condition that prevented it from fulfilling the request. Failed to get the client for the request.", - }, - { - "ShouldUpdateValues", - func(ctx oidc.Context) { - c := ctx.(*TestContext) - - c.Clock = clock.NewFixed(time.Unix(10000000000, 0)) - }, - &TestContext{ - IssuerURLFunc: func() (issuerURL *url.URL, err error) { - return &url.URL{Scheme: "https", Host: "example.com"}, nil - }, - }, - &fosite.AccessRequest{ - Request: fosite.Request{ - Client: &oidc.BaseClient{ - ID: abc, - }, - }, - }, - oidc.NewSession(), - &oidc.Session{ - Extra: map[string]any{}, - DefaultSession: &openid.DefaultSession{ - Headers: &fjwt.Headers{ - Extra: map[string]any{}, - }, - Claims: &fjwt.IDTokenClaims{ - Issuer: "https://example.com", - IssuedAt: time.Unix(10000000000, 0).UTC(), - RequestedAt: time.Unix(10000000000, 0).UTC(), - Subject: abc, - Extra: map[string]any{}, - }, - }, - ClientID: abc, - }, - "", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - if tc.setup != nil { - tc.setup(tc.ctx) - } - - err := oidc.PopulateClientCredentialsFlowSessionWithAccessRequest(tc.ctx, tc.request, tc.have, nil) - - assert.Equal(t, "", tc.have.GetSubject()) - - if len(tc.err) == 0 { - assert.NoError(t, err) - assert.EqualValues(t, tc.expected, tc.have) - } else { - assert.EqualError(t, oidc.ErrorToDebugRFC6749Error(err), tc.err) - } - }) - } -} - // TestContext is a minimal implementation of Context for the purpose of testing. type TestContext struct { context.Context diff --git a/internal/oidc/util.go b/internal/oidc/util.go index 81b8789e0..75cf2b02c 100644 --- a/internal/oidc/util.go +++ b/internal/oidc/util.go @@ -3,6 +3,7 @@ package oidc import ( "errors" "fmt" + "sort" "strings" "time" @@ -366,6 +367,70 @@ func IsJWTProfileAccessToken(token *fjwt.Token) bool { return ok && (typ == JWTHeaderTypeValueAccessTokenJWT) } +// RFC6750Header turns a *fosite.RFC6749Error into the values for a RFC6750 format WWW-Authenticate Bearer response +// header, excluding the Bearer prefix. +func RFC6750Header(realm, scope string, err *fosite.RFC6749Error) string { + values := err.ToValues() + + if realm != "" { + values.Set("realm", realm) + } + + if scope != "" { + values.Set("scope", scope) + } + + //nolint:prealloc + var ( + keys []string + key string + ) + + for key = range values { + keys = append(keys, key) + } + + sort.Slice(keys, func(i, j int) bool { + switch keys[i] { + case fieldRFC6750Realm: + return true + case fieldRFC6750Error: + switch keys[j] { + case fieldRFC6750ErrorDescription, fieldRFC6750Scope: + return true + default: + return false + } + case fieldRFC6750ErrorDescription: + switch keys[j] { + case fieldRFC6750Scope: + return true + default: + return false + } + case fieldRFC6750Scope: + switch keys[j] { + case fieldRFC6750Realm, fieldRFC6750Error, fieldRFC6750ErrorDescription: + return false + default: + return keys[i] < keys[j] + } + default: + return keys[i] < keys[j] + } + }) + + parts := make([]string, len(keys)) + + var i int + + for i, key = range keys { + parts[i] = fmt.Sprintf(`%s="%s"`, key, values.Get(key)) + } + + return strings.Join(parts, ",") +} + // AccessResponderToClearMap returns a clear friendly map copy of the responder map values. func AccessResponderToClearMap(responder fosite.AccessResponder) map[string]any { m := responder.ToMap() diff --git a/internal/oidc/util_blackbox_test.go b/internal/oidc/util_blackbox_test.go index 3cafc5bf9..65b07f00a 100644 --- a/internal/oidc/util_blackbox_test.go +++ b/internal/oidc/util_blackbox_test.go @@ -55,6 +55,43 @@ func TestSortedJSONWebKey(t *testing.T) { } } +func TestRFC6750Header(t *testing.T) { + testCaes := []struct { + name string + have *fosite.RFC6749Error + realm string + scope string + expected string + }{ + { + "ShouldEncodeAll", + &fosite.RFC6749Error{ + ErrorField: "invalid_example", + DescriptionField: "A description", + }, + "abc", + "openid", + `realm="abc",error="invalid_example",error_description="A description",scope="openid"`, + }, + { + "ShouldEncodeBasic", + &fosite.RFC6749Error{ + ErrorField: "invalid_example", + DescriptionField: "A description", + }, + "", + "", + `error="invalid_example",error_description="A description"`, + }, + } + + for _, tc := range testCaes { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, oidc.RFC6750Header(tc.realm, tc.scope, tc.have)) + }) + } +} + func TestIntrospectionResponseToMap(t *testing.T) { testCases := []struct { name string diff --git a/internal/random/mathematical_test.go b/internal/random/mathematical_test.go index b2f296dfa..675c0edfa 100644 --- a/internal/random/mathematical_test.go +++ b/internal/random/mathematical_test.go @@ -42,7 +42,7 @@ func TestMathematical(t *testing.T) { assert.Len(t, strdata, 11) i := p.Intn(999) - assert.Greater(t, i, 0) + assert.Greater(t, i, -1) assert.Less(t, i, 999) i, err = p.IntnErr(999) diff --git a/internal/server/locales/en/portal.json b/internal/server/locales/en/portal.json index 913454f32..2477440d7 100644 --- a/internal/server/locales/en/portal.json +++ b/internal/server/locales/en/portal.json @@ -1,5 +1,6 @@ { "Accept": "Accept", + "Access protected resources logged in as you": "Access protected resources logged in as you", "Access your email addresses": "Access your email addresses", "Access your group membership": "Access your group membership", "Access your profile information": "Access your profile information", diff --git a/internal/server/server.go b/internal/server/server.go index 075f29b31..564460155 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,7 +15,7 @@ import ( "github.com/authelia/authelia/v4/internal/middlewares" ) -// CreateDefaultServer Create Authelia's internal webserver with the given configuration and providers. +// CreateDefaultServer Create Authelia's internal web server with the given configuration and providers. func CreateDefaultServer(config *schema.Configuration, providers middlewares.Providers) (server *fasthttp.Server, listener net.Listener, paths []string, isTLS bool, err error) { if err = providers.Templates.LoadTemplatedAssets(assets); err != nil { return nil, nil, nil, false, fmt.Errorf("failed to load templated assets: %w", err) diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index 5f7db217e..fe44a8e0c 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -17,6 +17,7 @@ import ( "sort" "strconv" "strings" + "syscall" "time" "github.com/google/uuid" @@ -29,6 +30,7 @@ func FuncMap() map[string]any { "fileContent": FuncFileContent, "secret": FuncSecret, "env": FuncGetEnv, + "mustEnv": FuncMustGetEnv, "expandenv": FuncExpandEnv, "split": FuncStringSplit, "splitList": FuncStringSplitList, @@ -134,7 +136,24 @@ func FuncGetEnv(key string) string { return "" } - return os.Getenv(key) + value, _ := syscall.Getenv(key) + + return value +} + +// FuncMustGetEnv is a special version of os.GetEnv that excludes secret keys and returns an error if it doesn't exist. +func FuncMustGetEnv(key string) (string, error) { + if isSecretEnvKey(key) { + return "", nil + } + + value, found := syscall.Getenv(key) + + if !found { + return "", fmt.Errorf("environment variable '%s' isn't set", key) + } + + return value, nil } // FuncHashSum is a helper function that provides similar functionality to helm sum funcs. diff --git a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx index b95ea241a..1a9ae8a9d 100644 --- a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx +++ b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx @@ -1,6 +1,6 @@ import React, { Fragment, ReactNode, useEffect, useState } from "react"; -import { AccountBox, Autorenew, CheckBox, Contacts, Drafts, Group } from "@mui/icons-material"; +import { AccountBox, Autorenew, CheckBox, Contacts, Drafts, Group, LockOpen } from "@mui/icons-material"; import { Button, Checkbox, @@ -41,6 +41,8 @@ function scopeNameToAvatar(id: string) { return <Group />; case "email": return <Drafts />; + case "authelia.bearer.authz": + return <LockOpen />; default: return <CheckBox />; } @@ -108,6 +110,8 @@ const ConsentView = function (props: Props) { return translate("Access your group membership"); case "email": return translate("Access your email addresses"); + case "authelia.bearer.authz": + return translate("Access protected resources logged in as you"); default: return id; } |
