diff --git a/config.template.yml b/config.template.yml index 6c6601435..71919fe07 100644 --- a/config.template.yml +++ b/config.template.yml @@ -595,7 +595,7 @@ notifier: ## Enables additional debug messages. # enable_client_debug_messages: false - ## SECURITY NOTICE: It's not recommended to change this option, and highly discouraged to have it below 8 for + ## SECURITY NOTICE: It's not recommended changing this option, and highly discouraged to have it below 8 for ## security reasons. # minimum_parameter_entropy: 8 @@ -611,36 +611,42 @@ notifier: ## The client secret is a shared secret between Authelia and the consumer of this client. # secret: this_is_a_secret + ## Sets the client to public. This should typically not be set, please see the documentation for usage. + # public: false + ## The policy to require for this client; one_factor or two_factor. # authorization_policy: two_factor + ## Audience this client is allowed to request. + # audience: [] + + ## Scopes this client is allowed to request. + # scopes: + # - openid + # - groups + # - email + # - profile + ## Redirect URI's specifies a list of valid case-sensitive callbacks for this client. # redirect_uris: # - https://oidc.example.com:8080/oauth2/callback - ## Scopes defines the valid scopes this client can request - # scopes: - # - openid - # - groups - # - email - # - profile - ## Grant Types configures which grants this client can obtain. ## It's not recommended to define this unless you know what you're doing. # grant_types: - # - refresh_token - # - authorization_code + # - refresh_token + # - authorization_code ## Response Types configures which responses this client can be sent. ## It's not recommended to define this unless you know what you're doing. # response_types: - # - code + # - code ## Response Modes configures which response modes this client supports. # response_modes: - # - form_post - # - query - # - fragment + # - form_post + # - query + # - fragment ## The algorithm used to sign userinfo endpoint responses for this client, either none or RS256. # userinfo_signing_algorithm: none diff --git a/docs/configuration/identity-providers/oidc.md b/docs/configuration/identity-providers/oidc.md index 2cd79d29b..b052c8850 100644 --- a/docs/configuration/identity-providers/oidc.md +++ b/docs/configuration/identity-providers/oidc.md @@ -34,7 +34,7 @@ for which stage will have each feature, and may evolve over time: - beta1 + beta1 (4.29.0) User Consent @@ -56,9 +56,27 @@ for which stage will have each feature, and may evolve over time: Per Client List of Valid Redirection URI's - beta2 1 + Confidential Client Type + + + beta2 (4.30.0) 1 Userinfo Endpoint (missed in beta1) + + Parameter Entropy Configuration + + + Token/Code Lifespan Configuration + + + Client Debug Messages + + + Client Audience + + + Public Client Type + beta3 1 Token Storage @@ -117,20 +135,22 @@ identity_providers: access_token_lifespan: 1h authorize_code_lifespan: 1m id_token_lifespan: 1h - refresh_token_lifespan: 720h + refresh_token_lifespan: 90m enable_client_debug_messages: false clients: - id: myapp description: My Application secret: this_is_a_secret + public: false authorization_policy: two_factor - redirect_uris: - - https://oidc.example.com:8080/oauth2/callback + audience: [] scopes: - openid - groups - email - profile + redirect_uris: + - https://oidc.example.com:8080/oauth2/callback grant_types: - refresh_token - authorization_code @@ -222,7 +242,7 @@ The maximum lifetime of an ID token. For more information read these docs about
type: string {: .label .label-config .label-purple } -default: 30d +default: 90m {: .label .label-config .label-blue } required: no {: .label .label-config .label-green } @@ -232,6 +252,11 @@ The maximum lifetime of a refresh token. The refresh token can be used to obtain new refresh tokens as well as access tokens or id tokens with an up-to-date expiration. For more information read these docs about [token lifespan]. +A good starting point is 50% more or 30 minutes more (which ever is less) time than the highest lifespan out of the +[access token lifespan](#access_token_lifespan), the [authorize code lifespan](#authorize_code_lifespan), and the +[id token lifespan](#id_token_lifespan). For instance the default for all of these is 60 minutes, so the default refresh +token lifespan is 90 minutes. + ### enable_client_debug_messages
@@ -296,14 +321,35 @@ A friendly description for this client shown in the UI. This defaults to the sam
type: string {: .label .label-config .label-purple } -required: yes -{: .label .label-config .label-red } +required: situational +{: .label .label-config .label-yellow }
The shared secret between Authelia and the application consuming this client. This secret must match the secret configured in the application. Currently this is stored in plain text. You must [generate this option yourself](#generating-a-random-secret). +This must be provided when the client is a confidential client type, and must be blank when using the public client +type. To set the client type to public see the [public](#public) configuration option. + +#### public + +
+type: bool +{: .label .label-config .label-purple } +default: false +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +This enables the public client type for this client. This is for clients that are not capable of maintaining +confidentiality of credentials, you can read more about client types in [RFC6749](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1). +This is particularly useful for SPA's and CLI tools. This option requires setting the [client secret](#secret) to a +blank string. + +In addition to the standard rules for redirect URIs, public clients can use the `urn:ietf:wg:oauth:2.0:oob` redirect URI. + #### authorization_policy
@@ -317,18 +363,16 @@ required: no The authorization policy for this client: either `one_factor` or `two_factor`. -#### redirect_uris +#### audience
type: list(string) -{: .label .label-config .label-purple } -required: yes -{: .label .label-config .label-red } +{: .label .label-config .label-purple } +required: no +{: .label .label-config .label-green }
-A list of valid callback URL´s this client will redirect to. All other callbacks will be considered -unsafe. The URL's are case-sensitive and they differ from application to application - the community has -provided [a list of URL´s for common applications](../../community/oidc-integrations.md). +A list of audiences this client is allowed to request. #### scopes @@ -345,6 +389,28 @@ A list of scopes to allow this client to consume. See [scope definitions](#scope information. The documentation for the application you want to use with Authelia will most-likely provide you with the scopes to allow. +#### redirect_uris + +
+type: list(string) +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +A list of valid callback URIs this client will redirect to. All other callbacks will be considered +unsafe. The URIs are case-sensitive and they differ from application to application - the community has +provided [a list of URL´s for common applications](../../community/oidc-integrations.md). + +Some restrictions that have been placed on clients and +their redirect URIs are as follows: + +1. If a client attempts to authorize with Authelia and its redirect URI is not listed in the client configuration the + attempt to authorize wil fail and an error will be generated. +2. The redirect URIs are case-sensitive. +3. The URI must include a scheme and that scheme must be one of `http` or `https`. +4. The client can ignore rule 3 and use `urn:ietf:wg:oauth:2.0:oob` if it is a [public](#public) client type. + #### grant_types
diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 6c6601435..71919fe07 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -595,7 +595,7 @@ notifier: ## Enables additional debug messages. # enable_client_debug_messages: false - ## SECURITY NOTICE: It's not recommended to change this option, and highly discouraged to have it below 8 for + ## SECURITY NOTICE: It's not recommended changing this option, and highly discouraged to have it below 8 for ## security reasons. # minimum_parameter_entropy: 8 @@ -611,36 +611,42 @@ notifier: ## The client secret is a shared secret between Authelia and the consumer of this client. # secret: this_is_a_secret + ## Sets the client to public. This should typically not be set, please see the documentation for usage. + # public: false + ## The policy to require for this client; one_factor or two_factor. # authorization_policy: two_factor + ## Audience this client is allowed to request. + # audience: [] + + ## Scopes this client is allowed to request. + # scopes: + # - openid + # - groups + # - email + # - profile + ## Redirect URI's specifies a list of valid case-sensitive callbacks for this client. # redirect_uris: # - https://oidc.example.com:8080/oauth2/callback - ## Scopes defines the valid scopes this client can request - # scopes: - # - openid - # - groups - # - email - # - profile - ## Grant Types configures which grants this client can obtain. ## It's not recommended to define this unless you know what you're doing. # grant_types: - # - refresh_token - # - authorization_code + # - refresh_token + # - authorization_code ## Response Types configures which responses this client can be sent. ## It's not recommended to define this unless you know what you're doing. # response_types: - # - code + # - code ## Response Modes configures which response modes this client supports. # response_modes: - # - form_post - # - query - # - fragment + # - form_post + # - query + # - fragment ## The algorithm used to sign userinfo endpoint responses for this client, either none or RS256. # userinfo_signing_algorithm: none diff --git a/internal/configuration/schema/identity_providers.go b/internal/configuration/schema/identity_providers.go index faeaffb77..90a1de667 100644 --- a/internal/configuration/schema/identity_providers.go +++ b/internal/configuration/schema/identity_providers.go @@ -25,12 +25,16 @@ type OpenIDConnectConfiguration struct { // OpenIDConnectClientConfiguration configuration for an OpenID Connect client. type OpenIDConnectClientConfiguration struct { - ID string `mapstructure:"id"` - Description string `mapstructure:"description"` - Secret string `mapstructure:"secret"` - RedirectURIs []string `mapstructure:"redirect_uris"` - Policy string `mapstructure:"authorization_policy"` + ID string `mapstructure:"id"` + Description string `mapstructure:"description"` + Secret string `mapstructure:"secret"` + Public bool `mapstructure:"public"` + + Policy string `mapstructure:"authorization_policy"` + + Audience []string `mapstructure:"audience"` Scopes []string `mapstructure:"scopes"` + RedirectURIs []string `mapstructure:"redirect_uris"` GrantTypes []string `mapstructure:"grant_types"` ResponseTypes []string `mapstructure:"response_types"` ResponseModes []string `mapstructure:"response_modes"` diff --git a/internal/configuration/validator/access_control_test.go b/internal/configuration/validator/access_control_test.go index 2e37f45d0..00c24cc37 100644 --- a/internal/configuration/validator/access_control_test.go +++ b/internal/configuration/validator/access_control_test.go @@ -192,7 +192,7 @@ func TestShouldReturnCorrectResultsForValidNetworkGroups(t *testing.T) { } validNetwork := IsNetworkGroupValid(config, "internal") - invalidNetwork := IsNetworkGroupValid(config, "127.0.0.1") + invalidNetwork := IsNetworkGroupValid(config, loopback) assert.True(t, validNetwork) assert.False(t, invalidNetwork) diff --git a/internal/configuration/validator/authentication_test.go b/internal/configuration/validator/authentication_test.go index 5b4477e35..f38ea9c1f 100644 --- a/internal/configuration/validator/authentication_test.go +++ b/internal/configuration/validator/authentication_test.go @@ -443,7 +443,7 @@ func (suite *LDAPAuthenticationBackendSuite) TestShouldHelpDetectNoInputPlacehol } func (suite *LDAPAuthenticationBackendSuite) TestShouldAdaptLDAPURL() { - suite.Assert().Equal("", validateLDAPURLSimple("127.0.0.1", suite.validator)) + suite.Assert().Equal("", validateLDAPURLSimple(loopback, suite.validator)) suite.Assert().False(suite.validator.HasWarnings()) suite.Require().Len(suite.validator.Errors(), 1) diff --git a/internal/configuration/validator/configuration_test.go b/internal/configuration/validator/configuration_test.go index 5e4f4db1b..76c439b32 100644 --- a/internal/configuration/validator/configuration_test.go +++ b/internal/configuration/validator/configuration_test.go @@ -12,7 +12,7 @@ import ( func newDefaultConfig() schema.Configuration { config := schema.Configuration{} - config.Host = "127.0.0.1" + config.Host = loopback config.Port = 9090 config.Logging.Level = "info" config.Logging.Format = "text" diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 24f597fe7..f6e33cd3b 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -1,5 +1,10 @@ package validator +const ( + loopback = "127.0.0.1" + oauth2InstalledApp = "urn:ietf:wg:oauth:2.0:oob" +) + const ( errFmtDeprecatedConfigurationKey = "[DEPRECATED] The %s configuration option is deprecated and will be " + "removed in %s, please use %s instead" @@ -14,11 +19,16 @@ const ( errFmtOIDCServerClientRedirectURI = "OIDC client with ID '%s' redirect URI %s has an invalid scheme '%s', " + "should be http or https" + errFmtOIDCClientRedirectURIPublic = "openid connect provider: client with ID '%s' redirect URI '%s' is " + + "only valid for the public client type, not the confidential client type" + errFmtOIDCClientRedirectURIAbsolute = "openid connect provider: client with ID '%s' redirect URI '%s' is invalid " + + "because it has no scheme when it should be http or https" errFmtOIDCServerClientRedirectURICantBeParsed = "OIDC client with ID '%s' has an invalid redirect URI '%s' " + "could not be parsed: %v" errFmtOIDCServerClientInvalidPolicy = "OIDC client with ID '%s' has an invalid policy '%s', " + "should be either 'one_factor' or 'two_factor'" - errFmtOIDCServerClientInvalidSecret = "OIDC client with ID '%s' has an empty secret" //nolint:gosec + errFmtOIDCServerClientInvalidSecret = "OIDC client with ID '%s' has an empty secret" //nolint:gosec + errFmtOIDCClientPublicInvalidSecret = "openid connect provider: client with ID '%s' is public but does not have an empty secret" //nolint:gosec errFmtOIDCServerClientInvalidScope = "OIDC client with ID '%s' has an invalid scope '%s', " + "must be one of: '%s'" errFmtOIDCServerClientInvalidGrantType = "OIDC client with ID '%s' has an invalid grant type '%s', " + diff --git a/internal/configuration/validator/identity_providers.go b/internal/configuration/validator/identity_providers.go index 211eefdf6..736b01cfb 100644 --- a/internal/configuration/validator/identity_providers.go +++ b/internal/configuration/validator/identity_providers.go @@ -68,8 +68,14 @@ func validateOIDCClients(configuration *schema.OpenIDConnectConfiguration, valid ids = append(ids, client.ID) } - if client.Secret == "" { - validator.Push(fmt.Errorf(errFmtOIDCServerClientInvalidSecret, client.ID)) + if client.Public { + if client.Secret != "" { + validator.Push(fmt.Errorf(errFmtOIDCClientPublicInvalidSecret, client.ID)) + } + } else { + if client.Secret == "" { + validator.Push(fmt.Errorf(errFmtOIDCServerClientInvalidSecret, client.ID)) + } } if client.Policy == "" { @@ -163,15 +169,29 @@ func validateOIDDClientUserinfoAlgorithm(c int, configuration *schema.OpenIDConn func validateOIDCClientRedirectURIs(client schema.OpenIDConnectClientConfiguration, validator *schema.StructValidator) { for _, redirectURI := range client.RedirectURIs { - parsedURI, err := url.Parse(redirectURI) + if redirectURI == oauth2InstalledApp { + if client.Public { + continue + } - if err != nil { - validator.Push(fmt.Errorf(errFmtOIDCServerClientRedirectURICantBeParsed, client.ID, redirectURI, err)) - break + validator.Push(fmt.Errorf(errFmtOIDCClientRedirectURIPublic, client.ID, redirectURI)) + + continue } - if parsedURI.Scheme != schemeHTTPS && parsedURI.Scheme != schemeHTTP { - validator.Push(fmt.Errorf(errFmtOIDCServerClientRedirectURI, client.ID, redirectURI, parsedURI.Scheme)) + parsedURL, err := url.Parse(redirectURI) + if err != nil { + validator.Push(fmt.Errorf(errFmtOIDCServerClientRedirectURICantBeParsed, client.ID, redirectURI, err)) + continue + } + + if !parsedURL.IsAbs() { + validator.Push(fmt.Errorf(errFmtOIDCClientRedirectURIAbsolute, client.ID, redirectURI)) + return + } + + if parsedURL.Scheme != schemeHTTPS && parsedURL.Scheme != schemeHTTP { + validator.Push(fmt.Errorf(errFmtOIDCServerClientRedirectURI, client.ID, redirectURI, parsedURL.Scheme)) } } } diff --git a/internal/configuration/validator/identity_providers_test.go b/internal/configuration/validator/identity_providers_test.go index 44e8e977d..ac51ca40f 100644 --- a/internal/configuration/validator/identity_providers_test.go +++ b/internal/configuration/validator/identity_providers_test.go @@ -84,13 +84,21 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { "http://abc@%two", }, }, + { + ID: "client-check-uri-abs", + Secret: "a-secret", + Policy: twoFactorPolicy, + RedirectURIs: []string{ + "google.com", + }, + }, }, }, } ValidateIdentityProviders(config, validator) - require.Len(t, validator.Errors(), 7) + require.Len(t, validator.Errors(), 8) assert.Equal(t, schema.DefaultOpenIDConnectClientConfiguration.Policy, config.OIDC.Clients[0].Policy) assert.EqualError(t, validator.Errors()[0], fmt.Sprintf(errFmtOIDCServerClientInvalidSecret, "")) @@ -98,8 +106,9 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) { assert.EqualError(t, validator.Errors()[2], fmt.Sprintf(errFmtOIDCServerClientInvalidPolicy, "a-client", "a-policy")) assert.EqualError(t, validator.Errors()[3], fmt.Sprintf(errFmtOIDCServerClientInvalidPolicy, "a-client", "a-policy")) assert.EqualError(t, validator.Errors()[4], fmt.Sprintf(errFmtOIDCServerClientRedirectURICantBeParsed, "client-check-uri-parse", "http://abc@%two", errors.New("parse \"http://abc@%two\": invalid URL escape \"%tw\""))) - assert.EqualError(t, validator.Errors()[5], "OIDC Server has one or more clients with an empty ID") - assert.EqualError(t, validator.Errors()[6], "OIDC Server has clients with duplicate ID's") + assert.EqualError(t, validator.Errors()[5], fmt.Sprintf(errFmtOIDCClientRedirectURIAbsolute, "client-check-uri-abs", "google.com")) + assert.EqualError(t, validator.Errors()[6], "OIDC Server has one or more clients with an empty ID") + assert.EqualError(t, validator.Errors()[7], "OIDC Server has clients with duplicate ID's") } func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadScopes(t *testing.T) { @@ -239,6 +248,85 @@ func TestValidateIdentityProvidersShouldRaiseWarningOnSecurityIssue(t *testing.T assert.EqualError(t, validator.Warnings()[0], "SECURITY ISSUE: OIDC minimum parameter entropy is configured to an unsafe value, it should be above 8 but it's configured to 1.") } +func TestValidateIdentityProvidersShouldRaiseErrorsOnInvalidClientTypes(t *testing.T) { + validator := schema.NewStructValidator() + config := &schema.IdentityProvidersConfiguration{ + OIDC: &schema.OpenIDConnectConfiguration{ + HMACSecret: "hmac1", + IssuerPrivateKey: "key2", + Clients: []schema.OpenIDConnectClientConfiguration{ + { + ID: "client-with-invalid-secret", + Secret: "a-secret", + Public: true, + Policy: "two_factor", + RedirectURIs: []string{ + "https://localhost", + }, + }, + { + ID: "client-with-bad-redirect-uri", + Secret: "a-secret", + Public: false, + Policy: "two_factor", + RedirectURIs: []string{ + oauth2InstalledApp, + }, + }, + }, + }, + } + + ValidateIdentityProviders(config, validator) + + require.Len(t, validator.Errors(), 2) + assert.Len(t, validator.Warnings(), 0) + + assert.EqualError(t, validator.Errors()[0], fmt.Sprintf(errFmtOIDCClientPublicInvalidSecret, "client-with-invalid-secret")) + assert.EqualError(t, validator.Errors()[1], fmt.Sprintf(errFmtOIDCClientRedirectURIPublic, "client-with-bad-redirect-uri", oauth2InstalledApp)) +} + +func TestValidateIdentityProvidersShouldNotRaiseErrorsOnValidPublicClients(t *testing.T) { + validator := schema.NewStructValidator() + config := &schema.IdentityProvidersConfiguration{ + OIDC: &schema.OpenIDConnectConfiguration{ + HMACSecret: "hmac1", + IssuerPrivateKey: "key2", + Clients: []schema.OpenIDConnectClientConfiguration{ + { + ID: "installed-app-client", + Public: true, + Policy: "two_factor", + RedirectURIs: []string{ + oauth2InstalledApp, + }, + }, + { + ID: "client-with-https-scheme", + Public: true, + Policy: "two_factor", + RedirectURIs: []string{ + "https://localhost:9000", + }, + }, + { + ID: "client-with-loopback", + Public: true, + Policy: "two_factor", + RedirectURIs: []string{ + "http://127.0.0.1", + }, + }, + }, + }, + } + + ValidateIdentityProviders(config, validator) + + assert.Len(t, validator.Errors(), 0) + assert.Len(t, validator.Warnings(), 0) +} + func TestValidateIdentityProvidersShouldSetDefaultValues(t *testing.T) { validator := schema.NewStructValidator() config := &schema.IdentityProvidersConfiguration{ diff --git a/internal/oidc/client.go b/internal/oidc/client.go index 8369e9f40..f4e984bd3 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -12,20 +12,21 @@ import ( // NewClient creates a new InternalClient. func NewClient(config schema.OpenIDConnectClientConfiguration) (client *InternalClient) { client = &InternalClient{ - ID: config.ID, - Description: config.Description, - Policy: authorization.PolicyToLevel(config.Policy), - Secret: []byte(config.Secret), + ID: config.ID, + Description: config.Description, + Secret: []byte(config.Secret), + Public: config.Public, + + Policy: authorization.PolicyToLevel(config.Policy), + + Audience: config.Audience, + Scopes: config.Scopes, RedirectURIs: config.RedirectURIs, GrantTypes: config.GrantTypes, ResponseTypes: config.ResponseTypes, - Scopes: config.Scopes, + ResponseModes: []fosite.ResponseModeType{fosite.ResponseModeDefault}, UserinfoSigningAlgorithm: config.UserinfoSigningAlgorithm, - - ResponseModes: []fosite.ResponseModeType{ - fosite.ResponseModeDefault, - }, } for _, mode := range config.ResponseModes { diff --git a/internal/oidc/types.go b/internal/oidc/types.go index 355caa3bb..b503dc81d 100644 --- a/internal/oidc/types.go +++ b/internal/oidc/types.go @@ -33,21 +33,21 @@ type OpenIDConnectStore struct { // InternalClient represents the client internally. type InternalClient struct { - ID string `json:"id"` - Description string `json:"-"` - Secret []byte `json:"client_secret,omitempty"` - RedirectURIs []string `json:"redirect_uris"` - GrantTypes []string `json:"grant_types"` - ResponseTypes []string `json:"response_types"` - Scopes []string `json:"scopes"` - Audience []string `json:"audience"` - Public bool `json:"public"` + ID string `json:"id"` + Description string `json:"-"` + Secret []byte `json:"client_secret,omitempty"` + Public bool `json:"public"` + Policy authorization.Level `json:"-"` + + Audience []string `json:"audience"` + Scopes []string `json:"scopes"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` ResponseModes []fosite.ResponseModeType `json:"response_modes"` UserinfoSigningAlgorithm string `json:"userinfo_signed_response_alg,omitempty"` - - Policy authorization.Level `json:"-"` } // KeyManager keeps track of all of the active/inactive rsa keys and provides them to services requiring them.