feat(oidc): implement client type public (#2171)

This implements the public option for clients which allows using Authelia as an OpenID Connect Provider for cli applications and SPA's where the client secret cannot be considered secure.
pull/2187/head
James Elliott 2021-07-15 21:02:03 +10:00 committed by GitHub
parent 0da770d900
commit 8342a46ba1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 284 additions and 83 deletions

View File

@ -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

View File

@ -34,7 +34,7 @@ for which stage will have each feature, and may evolve over time:
</thead>
<tbody>
<tr>
<td rowspan="7" class="tbl-header tbl-beta-stage">beta1</td>
<td rowspan="8" class="tbl-header tbl-beta-stage">beta1 (4.29.0)</td>
<td><a href="https://openid.net/specs/openid-connect-core-1_0.html#Consent" target="_blank" rel="noopener noreferrer">User Consent</a></td>
</tr>
<tr>
@ -56,9 +56,27 @@ for which stage will have each feature, and may evolve over time:
<td class="tbl-beta-stage">Per Client List of Valid Redirection URI's</td>
</tr>
<tr>
<td rowspan="1" class="tbl-header tbl-beta-stage">beta2 <sup>1</sup></td>
<td class="tbl-beta-stage"><a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.1" target="_blank"rel="noopener noreferrer">Confidential Client Type</a></td>
</tr>
<tr>
<td rowspan="6" class="tbl-header tbl-beta-stage">beta2 (4.30.0) <sup>1</sup></td>
<td class="tbl-beta-stage"><a href="https://openid.net/specs/openid-connect-core-1_0.html#UserInfo" target="_blank" rel="noopener noreferrer">Userinfo Endpoint</a> (missed in beta1)</td>
</tr>
<tr>
<td class="tbl-beta-stage">Parameter Entropy Configuration</td>
</tr>
<tr>
<td class="tbl-beta-stage">Token/Code Lifespan Configuration</td>
</tr>
<tr>
<td class="tbl-beta-stage">Client Debug Messages</td>
</tr>
<tr>
<td class="tbl-beta-stage">Client Audience</td>
</tr>
<tr>
<td class="tbl-beta-stage"><a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.1" target="_blank"rel="noopener noreferrer">Public Client Type</a></td>
</tr>
<tr>
<td rowspan="2" class="tbl-header tbl-beta-stage">beta3 <sup>1</sup></td>
<td>Token Storage</td>
@ -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
<div markdown="1">
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
<div markdown="1">
@ -296,14 +321,35 @@ A friendly description for this client shown in the UI. This defaults to the sam
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
required: situational
{: .label .label-config .label-yellow }
</div>
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
<div markdown="1">
type: bool
{: .label .label-config .label-purple }
default: false
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
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
<div markdown="1">
@ -317,18 +363,16 @@ required: no
The authorization policy for this client: either `one_factor` or `two_factor`.
#### redirect_uris
#### audience
<div markdown="1">
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 }
</div>
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
<div markdown="1">
type: list(string)
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
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
<div markdown="1">

View File

@ -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

View File

@ -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"`

View File

@ -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)

View File

@ -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)

View File

@ -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"

View File

@ -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', " +

View File

@ -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))
}
}
}

View File

@ -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{

View File

@ -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 {

View File

@ -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.