feat(oidc): add additional config options, accurate token times, and refactoring (#1991)
* This gives admins more control over their OIDC installation exposing options that had defaults before. Things like lifespans for authorize codes, access tokens, id tokens, refresh tokens, a option to enable the debug client messages, minimum parameter entropy. It also allows admins to configure the response modes. * Additionally this records specific values about a users session indicating when they performed a specific authz factor so this is represented in the token accurately. * Lastly we also implemented a OIDC key manager which calculates the kid for jwk's using the SHA1 digest instead of being static, or more specifically the first 7 chars. As per https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key#section-8.1.1 the kid should not exceed 8 chars. While it's allowed to exceed 8 chars, it must only be done so with a compelling reason, which we do not have.pull/2141/head^2
parent
2dbd7ed219
commit
ef549f851d
|
@ -133,8 +133,8 @@ func startServer() {
|
|||
authorizer := authorization.NewAuthorizer(config)
|
||||
sessionProvider := session.NewProvider(config.Session, autheliaCertPool)
|
||||
regulator := regulation.NewRegulator(config.Regulation, storageProvider, clock)
|
||||
oidcProvider, err := oidc.NewOpenIDConnectProvider(config.IdentityProviders.OIDC)
|
||||
|
||||
oidcProvider, err := oidc.NewOpenIDConnectProvider(config.IdentityProviders.OIDC)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error initializing OpenID Connect Provider: %+v", err)
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ port: 9091
|
|||
## They should be in base64 format, and have one of the following extensions: *.cer, *.crt, *.pem.
|
||||
# certificates_directory: /config/certificates
|
||||
|
||||
## The theme to display: light, dark, grey.
|
||||
## The theme to display: light, dark, grey, auto.
|
||||
theme: light
|
||||
|
||||
##
|
||||
|
@ -65,7 +65,7 @@ jwt_secret: a_very_important_secret
|
|||
## in such a case.
|
||||
##
|
||||
## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication.
|
||||
default_redirection_url: https://home.example.com:8080/
|
||||
default_redirection_url: https://home.example.com/
|
||||
|
||||
##
|
||||
## TOTP Configuration
|
||||
|
@ -586,6 +586,19 @@ notifier:
|
|||
# --- KEY START
|
||||
# --- KEY END
|
||||
|
||||
## The lifespans configure the expiration for these token types.
|
||||
# access_token_lifespan: 1h
|
||||
# authorize_code_lifespan: 1m
|
||||
# id_token_lifespan: 1h
|
||||
# refresh_token_lifespan: 90m
|
||||
|
||||
## 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 reasons.
|
||||
# minimum_parameter_entropy: 8
|
||||
|
||||
## Clients is a list of known clients and their configuration.
|
||||
# clients:
|
||||
# -
|
||||
|
@ -616,10 +629,16 @@ notifier:
|
|||
## It's not recommended to define this unless you know what you're doing.
|
||||
# grant_types:
|
||||
# - refresh_token
|
||||
# - "authorization_code
|
||||
# - 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
|
||||
|
||||
## Response Modes configures which response modes this client supports.
|
||||
# response_modes:
|
||||
# - form_post
|
||||
# - query
|
||||
# - fragment
|
||||
...
|
||||
|
|
|
@ -102,6 +102,11 @@ identity_providers:
|
|||
issuer_private_key: |
|
||||
--- KEY START
|
||||
--- KEY END
|
||||
access_token_lifespan: 1h
|
||||
authorize_code_lifespan: 1m
|
||||
id_token_lifespan: 1h
|
||||
refresh_token_lifespan: 720h
|
||||
enable_client_debug_messages: false
|
||||
clients:
|
||||
- id: myapp
|
||||
description: My Application
|
||||
|
@ -119,11 +124,21 @@ identity_providers:
|
|||
- authorization_code
|
||||
response_types:
|
||||
- code
|
||||
response_modes:
|
||||
- form_post
|
||||
- query
|
||||
- fragment
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### hmac_secret
|
||||
<div markdown="1">
|
||||
type: string
|
||||
{: .label .label-config .label-purple }
|
||||
required: yes
|
||||
{: .label .label-config .label-red }
|
||||
</div>
|
||||
|
||||
The HMAC secret used to sign the [OpenID Connect] JWT's. The provided string is hashed to a SHA256 byte string for
|
||||
the purpose of meeting the required format.
|
||||
|
@ -131,49 +146,213 @@ the purpose of meeting the required format.
|
|||
Can also be defined using a [secret](../secrets.md) which is the recommended for containerized deployments.
|
||||
|
||||
### issuer_private_key
|
||||
<div markdown="1">
|
||||
type: string
|
||||
{: .label .label-config .label-purple }
|
||||
required: yes
|
||||
{: .label .label-config .label-red }
|
||||
</div>
|
||||
|
||||
The private key in DER base64 encoded PEM format used to encrypt the [OpenID Connect] JWT's.
|
||||
The private key in DER base64 encoded PEM format used to encrypt the [OpenID Connect] JWT's. This can easily be
|
||||
generated using the Authelia binary using the following syntax:
|
||||
|
||||
```console
|
||||
authelia rsa generate --dir /config
|
||||
```
|
||||
|
||||
Can also be defined using a [secret](../secrets.md) which is the recommended for containerized deployments.
|
||||
|
||||
### access_token_lifespan
|
||||
<div markdown="1">
|
||||
type: duration
|
||||
{: .label .label-config .label-purple }
|
||||
default: 1h
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
The maximum lifetime of an access token. It's generally recommended keeping this short similar to the default.
|
||||
For more information read these docs about [token lifespan].
|
||||
|
||||
### authorize_code_lifespan
|
||||
<div markdown="1">
|
||||
type: duration
|
||||
{: .label .label-config .label-purple }
|
||||
default: 1m
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
The maximum lifetime of an authorize code. This can be rather short, as the authorize code should only be needed to
|
||||
obtain the other token types. For more information read these docs about [token lifespan].
|
||||
|
||||
### id_token_lifespan
|
||||
<div markdown="1">
|
||||
type: duration
|
||||
{: .label .label-config .label-purple }
|
||||
default: 1h
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
The maximum lifetime of an ID token. For more information read these docs about [token lifespan].
|
||||
|
||||
### refresh_token_lifespan
|
||||
<div markdown="1">
|
||||
type: string
|
||||
{: .label .label-config .label-purple }
|
||||
default: 30d
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
The maximum lifetime of a refresh token. This should typically be slightly more the other token lifespans. This is
|
||||
because 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].
|
||||
|
||||
### enable_client_debug_messages
|
||||
<div markdown="1">
|
||||
type: boolean
|
||||
{: .label .label-config .label-purple }
|
||||
default: false
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
Allows additional debug messages to be sent to the clients.
|
||||
|
||||
### minimum_parameter_entropy
|
||||
<div markdown="1">
|
||||
type: integer
|
||||
{: .label .label-config .label-purple }
|
||||
default: 8
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
This controls the minimum length of the `nonce` and `state` parameters.
|
||||
|
||||
***Security Notice:*** Changing this value is generally discouraged, reducing it from the default can theoretically make
|
||||
certain scenarios less secure. It highly encouraged that if your OpenID Connect RP does not send these parameters or
|
||||
sends parameters with a lower length than the default that they implement a change rather than changing this value.
|
||||
|
||||
### clients
|
||||
|
||||
A list of clients to configure. The options for each client are described below.
|
||||
|
||||
#### id
|
||||
<div markdown="1">
|
||||
type: string
|
||||
{: .label .label-config .label-purple }
|
||||
required: yes
|
||||
{: .label .label-config .label-red }
|
||||
</div>
|
||||
|
||||
The Client ID for this client. Must be configured in the application consuming this client.
|
||||
|
||||
#### description
|
||||
<div markdown="1">
|
||||
type: string
|
||||
{: .label .label-config .label-purple }
|
||||
default: *same as id*
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
A friendly description for this client shown in the UI. This defaults to the same as the ID.
|
||||
|
||||
#### secret
|
||||
<div markdown="1">
|
||||
type: string
|
||||
{: .label .label-config .label-purple }
|
||||
required: yes
|
||||
{: .label .label-config .label-red }
|
||||
</div>
|
||||
|
||||
The shared secret between Authelia and the application consuming this client. Currently this is stored in plain text.
|
||||
|
||||
#### authorization_policy
|
||||
<div markdown="1">
|
||||
type: string
|
||||
{: .label .label-config .label-purple }
|
||||
default: two_factor
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
The authorization policy for this client. Either `one_factor` or `two_factor`.
|
||||
|
||||
#### 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 URL's this client will redirect to. All other callbacks will be considered unsafe. The URL's
|
||||
are case-sensitive.
|
||||
|
||||
#### scopes
|
||||
<div markdown="1">
|
||||
type: list(string)
|
||||
{: .label .label-config .label-purple }
|
||||
default: openid, groups, profile, email
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
A list of scopes to allow this client to consume. See [scope definitions](#scope-definitions) for more information.
|
||||
|
||||
#### grant_types
|
||||
<div markdown="1">
|
||||
type: list(string)
|
||||
{: .label .label-config .label-purple }
|
||||
default: refresh_token, authorization_code
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
A list of grant types this client can return. It is recommended that this isn't configured at this time unless you know
|
||||
what you're doing.
|
||||
what you're doing. Valid options are: `implicit`, `refresh_token`, `authorization_code`, `password`,
|
||||
`client_credentials`.
|
||||
|
||||
#### response_types
|
||||
<div markdown="1">
|
||||
type: list(string)
|
||||
{: .label .label-config .label-purple }
|
||||
default: code
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
A list of response types this client can return. It is recommended that this isn't configured at this time unless you
|
||||
know what you're doing.
|
||||
know what you're doing. Valid options are: `code`, `code id_token`, `id_token`, `token id_token`, `token`,
|
||||
`token id_token code`.
|
||||
|
||||
#### response_modes
|
||||
<div markdown="1">
|
||||
type: list(string)
|
||||
{: .label .label-config .label-purple }
|
||||
default: form_post, query, fragment
|
||||
{: .label .label-config .label-blue }
|
||||
required: no
|
||||
{: .label .label-config .label-green }
|
||||
</div>
|
||||
|
||||
A list of response modes this client can return. It is recommended that this isn't configured at this time unless you
|
||||
know what you're doing. Potential values are `form_post`, `query`, and `fragment`.
|
||||
|
||||
## Scope Definitions
|
||||
|
||||
|
@ -183,17 +362,17 @@ This is the default scope for openid. This field is forced on every client by th
|
|||
validation that Authelia does.
|
||||
|
||||
|JWT Field|JWT Type |Authelia Attribute|Description |
|
||||
|:-------:|:-----------:|:----------------:|:--------------------------------------:|
|
||||
|sub |string |Username |The username the user used to login with|
|
||||
|:-------:|:-----------:|:----------------:|:-------------------------------------------:|
|
||||
|sub |string |Username |The username the user used to login with |
|
||||
|scope |string |scopes |Granted scopes (space delimited) |
|
||||
|scp |array[string]|scopes |Granted scopes |
|
||||
|iss |string |hostname |The issuer name, determined by URL |
|
||||
|at_hash |string |_N/A_ |Access Token Hash |
|
||||
|auth_time|number |_N/A_ |Authorize Time |
|
||||
|aud |array[string]|_N/A_ |Audience |
|
||||
|exp |number |_N/A_ |Expires |
|
||||
|iat |number |_N/A_ |Issued At |
|
||||
|rat |number |_N/A_ |Requested At |
|
||||
|auth_time|number |_N/A_ |The time the user authenticated with Authelia|
|
||||
|rat |number |_N/A_ |The time when the token was requested |
|
||||
|iat |number |_N/A_ |The time when the token was issued |
|
||||
|jti |string(uuid) |_N/A_ |JWT Identifier |
|
||||
|
||||
### groups
|
||||
|
@ -208,10 +387,11 @@ This scope includes the groups the authentication backend reports the user is a
|
|||
|
||||
This scope includes the email information the authentication backend reports about the user in the token.
|
||||
|
||||
|JWT Field |JWT Type|Authelia Attribute|Description |
|
||||
|:------------:|:------:|:----------------:|:-------------------------------------------------------:|
|
||||
|email |string |email[0] |The first email in the list of emails |
|
||||
|JWT Field |JWT Type |Authelia Attribute|Description |
|
||||
|:------------:|:-----------:|:----------------:|:-------------------------------------------------------:|
|
||||
|email |string |email[0] |The first email address in the list of emails |
|
||||
|email_verified|bool |_N/A_ |If the email is verified, assumed true for the time being|
|
||||
|alt_emails |array[string]|email[1:] |All email addresses that are not in the email JWT field |
|
||||
|
||||
### profile
|
||||
|
||||
|
@ -223,3 +403,4 @@ This scope includes the profile information the authentication backend reports a
|
|||
|
||||
|
||||
[OpenID Connect]: https://openid.net/connect/
|
||||
[token lifespan]: https://docs.apigee.com/api-platform/antipatterns/oauth-long-expiration
|
|
@ -65,7 +65,7 @@ jwt_secret: a_very_important_secret
|
|||
## in such a case.
|
||||
##
|
||||
## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication.
|
||||
default_redirection_url: https://home.example.com:8080/
|
||||
default_redirection_url: https://home.example.com/
|
||||
|
||||
##
|
||||
## TOTP Configuration
|
||||
|
@ -586,6 +586,19 @@ notifier:
|
|||
# --- KEY START
|
||||
# --- KEY END
|
||||
|
||||
## The lifespans configure the expiration for these token types.
|
||||
# access_token_lifespan: 1h
|
||||
# authorize_code_lifespan: 1m
|
||||
# id_token_lifespan: 1h
|
||||
# refresh_token_lifespan: 90m
|
||||
|
||||
## 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 reasons.
|
||||
# minimum_parameter_entropy: 8
|
||||
|
||||
## Clients is a list of known clients and their configuration.
|
||||
# clients:
|
||||
# -
|
||||
|
@ -616,10 +629,16 @@ notifier:
|
|||
## It's not recommended to define this unless you know what you're doing.
|
||||
# grant_types:
|
||||
# - refresh_token
|
||||
# - "authorization_code
|
||||
# - 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
|
||||
|
||||
## Response Modes configures which response modes this client supports.
|
||||
# response_modes:
|
||||
# - form_post
|
||||
# - query
|
||||
# - fragment
|
||||
...
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package schema
|
||||
|
||||
import "time"
|
||||
|
||||
// IdentityProvidersConfiguration represents the IdentityProviders 2.0 configuration for Authelia.
|
||||
type IdentityProvidersConfiguration struct {
|
||||
OIDC *OpenIDConnectConfiguration `mapstructure:"oidc"`
|
||||
|
@ -11,6 +13,13 @@ type OpenIDConnectConfiguration struct {
|
|||
HMACSecret string `mapstructure:"hmac_secret"`
|
||||
IssuerPrivateKey string `mapstructure:"issuer_private_key"`
|
||||
|
||||
AccessTokenLifespan time.Duration `mapstructure:"access_token_lifespan"`
|
||||
AuthorizeCodeLifespan time.Duration `mapstructure:"authorize_code_lifespan"`
|
||||
IDTokenLifespan time.Duration `mapstructure:"id_token_lifespan"`
|
||||
RefreshTokenLifespan time.Duration `mapstructure:"refresh_token_lifespan"`
|
||||
EnableClientDebugMessages bool `mapstructure:"enable_client_debug_messages"`
|
||||
MinimumParameterEntropy int `mapstructure:"minimum_parameter_entropy"`
|
||||
|
||||
Clients []OpenIDConnectClientConfiguration `mapstructure:"clients"`
|
||||
}
|
||||
|
||||
|
@ -24,12 +33,22 @@ type OpenIDConnectClientConfiguration struct {
|
|||
Scopes []string `mapstructure:"scopes"`
|
||||
GrantTypes []string `mapstructure:"grant_types"`
|
||||
ResponseTypes []string `mapstructure:"response_types"`
|
||||
ResponseModes []string `mapstructure:"response_modes"`
|
||||
}
|
||||
|
||||
// DefaultOpenIDConnectClientConfiguration contains defaults for OIDC AutheliaClients.
|
||||
var DefaultOpenIDConnectClientConfiguration = OpenIDConnectClientConfiguration{
|
||||
Scopes: []string{"openid", "groups", "profile", "email"},
|
||||
ResponseTypes: []string{"code"},
|
||||
GrantTypes: []string{"refresh_token", "authorization_code"},
|
||||
Policy: "two_factor",
|
||||
// DefaultOpenIDConnectConfiguration contains defaults for OIDC.
|
||||
var DefaultOpenIDConnectConfiguration = OpenIDConnectConfiguration{
|
||||
AccessTokenLifespan: time.Hour,
|
||||
AuthorizeCodeLifespan: time.Minute,
|
||||
IDTokenLifespan: time.Hour,
|
||||
RefreshTokenLifespan: time.Minute * 90,
|
||||
}
|
||||
|
||||
// DefaultOpenIDConnectClientConfiguration contains defaults for OIDC Clients.
|
||||
var DefaultOpenIDConnectClientConfiguration = OpenIDConnectClientConfiguration{
|
||||
Policy: "two_factor",
|
||||
Scopes: []string{"openid", "groups", "profile", "email"},
|
||||
GrantTypes: []string{"refresh_token", "authorization_code"},
|
||||
ResponseTypes: []string{"code"},
|
||||
ResponseModes: []string{"form_post", "query", "fragment"},
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@ package validator
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
var defaultPort = 9091
|
||||
|
@ -41,9 +41,9 @@ func ValidateConfiguration(configuration *schema.Configuration, validator *schem
|
|||
}
|
||||
|
||||
if configuration.DefaultRedirectionURL != "" {
|
||||
_, err := url.ParseRequestURI(configuration.DefaultRedirectionURL)
|
||||
err := utils.IsStringAbsURL(configuration.DefaultRedirectionURL)
|
||||
if err != nil {
|
||||
validator.Push(fmt.Errorf("Unable to parse default redirection url"))
|
||||
validator.Push(fmt.Errorf("Value for \"default_redirection_url\" is invalid: %+v", err))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -150,11 +150,11 @@ func TestShouldRaiseErrorWithUndefinedJWTSecretKey(t *testing.T) {
|
|||
func TestShouldRaiseErrorWithBadDefaultRedirectionURL(t *testing.T) {
|
||||
validator := schema.NewStructValidator()
|
||||
config := newDefaultConfig()
|
||||
config.DefaultRedirectionURL = "abc"
|
||||
config.DefaultRedirectionURL = "bad_default_redirection_url"
|
||||
|
||||
ValidateConfiguration(&config, validator)
|
||||
require.Len(t, validator.Errors(), 1)
|
||||
assert.EqualError(t, validator.Errors()[0], "Unable to parse default redirection url")
|
||||
assert.EqualError(t, validator.Errors()[0], "Value for \"default_redirection_url\" is invalid: the url 'bad_default_redirection_url' is not absolute because it doesn't start with a scheme like 'http://' or 'https://'")
|
||||
}
|
||||
|
||||
func TestShouldNotOverrideCertificatesDirectoryAndShouldPassWhenBlank(t *testing.T) {
|
||||
|
|
|
@ -12,14 +12,28 @@ const (
|
|||
errFmtSessionRedisHostRequired = "The host must be provided when using the %s session provider"
|
||||
errFmtSessionRedisHostOrNodesRequired = "Either the host or a node must be provided when using the %s session provider"
|
||||
|
||||
errOAuthOIDCServerClientRedirectURIFmt = "OIDC Server Client redirect URI %s has an invalid scheme %s, should be http or https"
|
||||
errOAuthOIDCServerClientRedirectURICantBeParsedFmt = "OIDC Client with ID '%s' has an invalid redirect URI '%s' could not be parsed: %v"
|
||||
errIdentityProvidersOIDCServerClientInvalidPolicyFmt = "OIDC Client with ID '%s' has an invalid policy '%s', should be either 'one_factor' or 'two_factor'"
|
||||
errIdentityProvidersOIDCServerClientInvalidSecFmt = "OIDC Client with ID '%s' has an empty secret"
|
||||
errFmtOIDCServerClientRedirectURI = "OIDC Client with ID '%s' redirect URI %s has an invalid scheme '%s', " +
|
||||
"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
|
||||
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', " +
|
||||
"must be one of: '%s'"
|
||||
errFmtOIDCServerClientInvalidResponseMode = "OIDC Client with ID '%s' has an invalid response mode '%s', " +
|
||||
"must be one of: '%s'"
|
||||
errFmtOIDCServerInsecureParameterEntropy = "SECURITY ISSUE: OIDC minimum parameter entropy is configured to an " +
|
||||
"unsafe value, it should be above 8 but it's configured to %d."
|
||||
|
||||
errFileHashing = "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password"
|
||||
errFilePHashing = "config key incorrect: authentication_backend.file.password_hashing should be authentication_backend.file.password"
|
||||
errFilePOptions = "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password"
|
||||
errFileHashing = "config key incorrect: authentication_backend.file.hashing should be " +
|
||||
"authentication_backend.file.password"
|
||||
errFilePHashing = "config key incorrect: authentication_backend.file.password_hashing should be " +
|
||||
"authentication_backend.file.password"
|
||||
errFilePOptions = "config key incorrect: authentication_backend.file.password_options should be " +
|
||||
"authentication_backend.file.password"
|
||||
|
||||
bypassPolicy = "bypass"
|
||||
oneFactorPolicy = "one_factor"
|
||||
|
@ -31,6 +45,8 @@ const (
|
|||
|
||||
schemeLDAP = "ldap"
|
||||
schemeLDAPS = "ldaps"
|
||||
schemeHTTP = "http"
|
||||
schemeHTTPS = "https"
|
||||
|
||||
testBadTimer = "-1"
|
||||
testInvalidPolicy = "invalid"
|
||||
|
@ -43,12 +59,16 @@ const (
|
|||
testTLSCert = "/tmp/cert.pem"
|
||||
testTLSKey = "/tmp/key.pem"
|
||||
|
||||
errAccessControlInvalidPolicyWithSubjects = "Policy [bypass] for rule #%d domain %s with subjects %s is invalid. It is " +
|
||||
"not supported to configure both policy bypass and subjects. For more information see: " +
|
||||
errAccessControlInvalidPolicyWithSubjects = "Policy [bypass] for rule #%d domain %s with subjects %s is invalid. " +
|
||||
"It is not supported to configure both policy bypass and subjects. For more information see: " +
|
||||
"https://www.authelia.com/docs/configuration/access-control.html#combining-subjects-and-the-bypass-policy"
|
||||
)
|
||||
|
||||
var validLoggingLevels = []string{"trace", "debug", "info", "warn", "error"}
|
||||
var validScopes = []string{"openid", "email", "profile", "groups", "offline_access"}
|
||||
var validOIDCGrantTypes = []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"}
|
||||
var validOIDCResponseModes = []string{"form_post", "query", "fragment"}
|
||||
|
||||
var validRequestMethods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS"}
|
||||
|
||||
// SecretNames contains a map of secret names.
|
||||
|
@ -211,6 +231,11 @@ var validKeys = []string{
|
|||
|
||||
// Identity Provider Keys.
|
||||
"identity_providers.oidc.clients",
|
||||
"identity_providers.oidc.id_token_lifespan",
|
||||
"identity_providers.oidc.access_token_lifespan",
|
||||
"identity_providers.oidc.refresh_token_lifespan",
|
||||
"identity_providers.oidc.authorize_code_lifespan",
|
||||
"identity_providers.oidc.enable_client_debug_messages",
|
||||
}
|
||||
|
||||
var replacedKeys = map[string]string{
|
||||
|
|
|
@ -3,6 +3,8 @@ package validator
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
|
@ -19,6 +21,26 @@ func validateOIDC(configuration *schema.OpenIDConnectConfiguration, validator *s
|
|||
validator.Push(fmt.Errorf("OIDC Server issuer private key must be provided"))
|
||||
}
|
||||
|
||||
if configuration.AccessTokenLifespan == time.Duration(0) {
|
||||
configuration.AccessTokenLifespan = schema.DefaultOpenIDConnectConfiguration.AccessTokenLifespan
|
||||
}
|
||||
|
||||
if configuration.AuthorizeCodeLifespan == time.Duration(0) {
|
||||
configuration.AuthorizeCodeLifespan = schema.DefaultOpenIDConnectConfiguration.AuthorizeCodeLifespan
|
||||
}
|
||||
|
||||
if configuration.IDTokenLifespan == time.Duration(0) {
|
||||
configuration.IDTokenLifespan = schema.DefaultOpenIDConnectConfiguration.IDTokenLifespan
|
||||
}
|
||||
|
||||
if configuration.RefreshTokenLifespan == time.Duration(0) {
|
||||
configuration.RefreshTokenLifespan = schema.DefaultOpenIDConnectConfiguration.RefreshTokenLifespan
|
||||
}
|
||||
|
||||
if configuration.MinimumParameterEntropy != 0 && configuration.MinimumParameterEntropy < 8 {
|
||||
validator.PushWarning(fmt.Errorf(errFmtOIDCServerInsecureParameterEntropy, configuration.MinimumParameterEntropy))
|
||||
}
|
||||
|
||||
validateOIDCClients(configuration, validator)
|
||||
|
||||
if len(configuration.Clients) == 0 {
|
||||
|
@ -47,28 +69,19 @@ func validateOIDCClients(configuration *schema.OpenIDConnectConfiguration, valid
|
|||
}
|
||||
|
||||
if client.Secret == "" {
|
||||
validator.Push(fmt.Errorf(errIdentityProvidersOIDCServerClientInvalidSecFmt, client.ID))
|
||||
validator.Push(fmt.Errorf(errFmtOIDCServerClientInvalidSecret, client.ID))
|
||||
}
|
||||
|
||||
if client.Policy == "" {
|
||||
configuration.Clients[c].Policy = schema.DefaultOpenIDConnectClientConfiguration.Policy
|
||||
} else if client.Policy != oneFactorPolicy && client.Policy != twoFactorPolicy {
|
||||
validator.Push(fmt.Errorf(errIdentityProvidersOIDCServerClientInvalidPolicyFmt, client.ID, client.Policy))
|
||||
validator.Push(fmt.Errorf(errFmtOIDCServerClientInvalidPolicy, client.ID, client.Policy))
|
||||
}
|
||||
|
||||
if len(client.Scopes) == 0 {
|
||||
configuration.Clients[c].Scopes = schema.DefaultOpenIDConnectClientConfiguration.Scopes
|
||||
} else if !utils.IsStringInSlice("openid", client.Scopes) {
|
||||
configuration.Clients[c].Scopes = append(configuration.Clients[c].Scopes, "openid")
|
||||
}
|
||||
|
||||
if len(client.GrantTypes) == 0 {
|
||||
configuration.Clients[c].GrantTypes = schema.DefaultOpenIDConnectClientConfiguration.GrantTypes
|
||||
}
|
||||
|
||||
if len(client.ResponseTypes) == 0 {
|
||||
configuration.Clients[c].ResponseTypes = schema.DefaultOpenIDConnectClientConfiguration.ResponseTypes
|
||||
}
|
||||
validateOIDCClientScopes(c, configuration, validator)
|
||||
validateOIDCClientGrantTypes(c, configuration, validator)
|
||||
validateOIDCClientResponseTypes(c, configuration, validator)
|
||||
validateOIDCClientResponseModes(c, configuration, validator)
|
||||
|
||||
validateOIDCClientRedirectURIs(client, validator)
|
||||
}
|
||||
|
@ -82,17 +95,73 @@ func validateOIDCClients(configuration *schema.OpenIDConnectConfiguration, valid
|
|||
}
|
||||
}
|
||||
|
||||
func validateOIDCClientScopes(c int, configuration *schema.OpenIDConnectConfiguration, validator *schema.StructValidator) {
|
||||
if len(configuration.Clients[c].Scopes) == 0 {
|
||||
configuration.Clients[c].Scopes = schema.DefaultOpenIDConnectClientConfiguration.Scopes
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.IsStringInSlice("openid", configuration.Clients[c].Scopes) {
|
||||
configuration.Clients[c].Scopes = append(configuration.Clients[c].Scopes, "openid")
|
||||
}
|
||||
|
||||
for _, scope := range configuration.Clients[c].Scopes {
|
||||
if !utils.IsStringInSlice(scope, validScopes) {
|
||||
validator.Push(fmt.Errorf(
|
||||
errFmtOIDCServerClientInvalidScope,
|
||||
configuration.Clients[c].ID, scope, strings.Join(validScopes, "', '")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateOIDCClientGrantTypes(c int, configuration *schema.OpenIDConnectConfiguration, validator *schema.StructValidator) {
|
||||
if len(configuration.Clients[c].GrantTypes) == 0 {
|
||||
configuration.Clients[c].GrantTypes = schema.DefaultOpenIDConnectClientConfiguration.GrantTypes
|
||||
return
|
||||
}
|
||||
|
||||
for _, grantType := range configuration.Clients[c].GrantTypes {
|
||||
if !utils.IsStringInSlice(grantType, validOIDCGrantTypes) {
|
||||
validator.Push(fmt.Errorf(
|
||||
errFmtOIDCServerClientInvalidGrantType,
|
||||
configuration.Clients[c].ID, grantType, strings.Join(validOIDCGrantTypes, "', '")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateOIDCClientResponseTypes(c int, configuration *schema.OpenIDConnectConfiguration, _ *schema.StructValidator) {
|
||||
if len(configuration.Clients[c].ResponseTypes) == 0 {
|
||||
configuration.Clients[c].ResponseTypes = schema.DefaultOpenIDConnectClientConfiguration.ResponseTypes
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func validateOIDCClientResponseModes(c int, configuration *schema.OpenIDConnectConfiguration, validator *schema.StructValidator) {
|
||||
if len(configuration.Clients[c].ResponseModes) == 0 {
|
||||
configuration.Clients[c].ResponseModes = schema.DefaultOpenIDConnectClientConfiguration.ResponseModes
|
||||
return
|
||||
}
|
||||
|
||||
for _, responseMode := range configuration.Clients[c].ResponseModes {
|
||||
if !utils.IsStringInSlice(responseMode, validOIDCResponseModes) {
|
||||
validator.Push(fmt.Errorf(
|
||||
errFmtOIDCServerClientInvalidResponseMode,
|
||||
configuration.Clients[c].ID, responseMode, strings.Join(validOIDCResponseModes, "', '")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateOIDCClientRedirectURIs(client schema.OpenIDConnectClientConfiguration, validator *schema.StructValidator) {
|
||||
for _, redirectURI := range client.RedirectURIs {
|
||||
parsedURI, err := url.Parse(redirectURI)
|
||||
|
||||
if err != nil {
|
||||
validator.Push(fmt.Errorf(errOAuthOIDCServerClientRedirectURICantBeParsedFmt, client.ID, redirectURI, err))
|
||||
validator.Push(fmt.Errorf(errFmtOIDCServerClientRedirectURICantBeParsed, client.ID, redirectURI, err))
|
||||
break
|
||||
}
|
||||
|
||||
if parsedURI.Scheme != "https" && parsedURI.Scheme != "http" {
|
||||
validator.Push(fmt.Errorf(errOAuthOIDCServerClientRedirectURIFmt, redirectURI, parsedURI.Scheme))
|
||||
if parsedURI.Scheme != schemeHTTPS && parsedURI.Scheme != schemeHTTP {
|
||||
validator.Push(fmt.Errorf(errFmtOIDCServerClientRedirectURI, client.ID, redirectURI, parsedURI.Scheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
@ -92,16 +93,126 @@ func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) {
|
|||
require.Len(t, validator.Errors(), 7)
|
||||
|
||||
assert.Equal(t, schema.DefaultOpenIDConnectClientConfiguration.Policy, config.OIDC.Clients[0].Policy)
|
||||
assert.EqualError(t, validator.Errors()[0], fmt.Sprintf(errIdentityProvidersOIDCServerClientInvalidSecFmt, ""))
|
||||
assert.EqualError(t, validator.Errors()[1], fmt.Sprintf(errOAuthOIDCServerClientRedirectURIFmt, "tcp://google.com", "tcp"))
|
||||
assert.EqualError(t, validator.Errors()[2], fmt.Sprintf(errIdentityProvidersOIDCServerClientInvalidPolicyFmt, "a-client", "a-policy"))
|
||||
assert.EqualError(t, validator.Errors()[3], fmt.Sprintf(errIdentityProvidersOIDCServerClientInvalidPolicyFmt, "a-client", "a-policy"))
|
||||
assert.EqualError(t, validator.Errors()[4], fmt.Sprintf(errOAuthOIDCServerClientRedirectURICantBeParsedFmt, "client-check-uri-parse", "http://abc@%two", errors.New("parse \"http://abc@%two\": invalid URL escape \"%tw\"")))
|
||||
assert.EqualError(t, validator.Errors()[0], fmt.Sprintf(errFmtOIDCServerClientInvalidSecret, ""))
|
||||
assert.EqualError(t, validator.Errors()[1], fmt.Sprintf(errFmtOIDCServerClientRedirectURI, "", "tcp://google.com", "tcp"))
|
||||
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")
|
||||
}
|
||||
|
||||
func TestShouldNotRaiseErrorWhenOIDCServerConfiguredCorrectly(t *testing.T) {
|
||||
func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadScopes(t *testing.T) {
|
||||
validator := schema.NewStructValidator()
|
||||
config := &schema.IdentityProvidersConfiguration{
|
||||
OIDC: &schema.OpenIDConnectConfiguration{
|
||||
HMACSecret: "rLABDrx87et5KvRHVUgTm3pezWWd8LMN",
|
||||
IssuerPrivateKey: "key-material",
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{
|
||||
{
|
||||
ID: "good_id",
|
||||
Secret: "good_secret",
|
||||
Policy: "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], "OIDC Client with ID 'good_id' has an invalid scope "+
|
||||
"'bad_scope', must be one of: 'openid', 'email', 'profile', 'groups', 'offline_access'")
|
||||
}
|
||||
|
||||
func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadGrantTypes(t *testing.T) {
|
||||
validator := schema.NewStructValidator()
|
||||
config := &schema.IdentityProvidersConfiguration{
|
||||
OIDC: &schema.OpenIDConnectConfiguration{
|
||||
HMACSecret: "rLABDrx87et5KvRHVUgTm3pezWWd8LMN",
|
||||
IssuerPrivateKey: "key-material",
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{
|
||||
{
|
||||
ID: "good_id",
|
||||
Secret: "good_secret",
|
||||
Policy: "two_factor",
|
||||
GrantTypes: []string{"bad_grant_type"},
|
||||
RedirectURIs: []string{
|
||||
"https://google.com/callback",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ValidateIdentityProviders(config, validator)
|
||||
|
||||
require.Len(t, validator.Errors(), 1)
|
||||
assert.EqualError(t, validator.Errors()[0], "OIDC Client with ID 'good_id' has an invalid grant type "+
|
||||
"'bad_grant_type', must be one of: 'implicit', 'refresh_token', 'authorization_code', "+
|
||||
"'password', 'client_credentials'")
|
||||
}
|
||||
|
||||
func TestShouldRaiseErrorWhenOIDCClientConfiguredWithBadResponseModes(t *testing.T) {
|
||||
validator := schema.NewStructValidator()
|
||||
config := &schema.IdentityProvidersConfiguration{
|
||||
OIDC: &schema.OpenIDConnectConfiguration{
|
||||
HMACSecret: "rLABDrx87et5KvRHVUgTm3pezWWd8LMN",
|
||||
IssuerPrivateKey: "key-material",
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{
|
||||
{
|
||||
ID: "good_id",
|
||||
Secret: "good_secret",
|
||||
Policy: "two_factor",
|
||||
ResponseModes: []string{"bad_responsemode"},
|
||||
RedirectURIs: []string{
|
||||
"https://google.com/callback",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ValidateIdentityProviders(config, validator)
|
||||
|
||||
require.Len(t, validator.Errors(), 1)
|
||||
assert.EqualError(t, validator.Errors()[0], "OIDC Client with ID 'good_id' has an invalid response mode "+
|
||||
"'bad_responsemode', must be one of: 'form_post', 'query', 'fragment'")
|
||||
}
|
||||
|
||||
func TestValidateIdentityProvidersShouldRaiseWarningOnSecurityIssue(t *testing.T) {
|
||||
validator := schema.NewStructValidator()
|
||||
config := &schema.IdentityProvidersConfiguration{
|
||||
OIDC: &schema.OpenIDConnectConfiguration{
|
||||
HMACSecret: "abc",
|
||||
IssuerPrivateKey: "abc",
|
||||
MinimumParameterEntropy: 1,
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{
|
||||
{
|
||||
ID: "good_id",
|
||||
Secret: "good_secret",
|
||||
Policy: "two_factor",
|
||||
RedirectURIs: []string{
|
||||
"https://google.com/callback",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ValidateIdentityProviders(config, validator)
|
||||
|
||||
assert.Len(t, validator.Errors(), 0)
|
||||
require.Len(t, validator.Warnings(), 1)
|
||||
|
||||
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 TestValidateIdentityProvidersShouldSetDefaultValues(t *testing.T) {
|
||||
validator := schema.NewStructValidator()
|
||||
config := &schema.IdentityProvidersConfiguration{
|
||||
OIDC: &schema.OpenIDConnectConfiguration{
|
||||
|
@ -111,7 +222,6 @@ func TestShouldNotRaiseErrorWhenOIDCServerConfiguredCorrectly(t *testing.T) {
|
|||
{
|
||||
ID: "a-client",
|
||||
Secret: "a-client-secret",
|
||||
Policy: oneFactorPolicy,
|
||||
RedirectURIs: []string{
|
||||
"https://google.com",
|
||||
},
|
||||
|
@ -134,6 +244,10 @@ func TestShouldNotRaiseErrorWhenOIDCServerConfiguredCorrectly(t *testing.T) {
|
|||
"token",
|
||||
"code",
|
||||
},
|
||||
ResponseModes: []string{
|
||||
"form_post",
|
||||
"fragment",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -141,32 +255,61 @@ func TestShouldNotRaiseErrorWhenOIDCServerConfiguredCorrectly(t *testing.T) {
|
|||
|
||||
ValidateIdentityProviders(config, validator)
|
||||
|
||||
assert.Len(t, validator.Warnings(), 0)
|
||||
assert.Len(t, validator.Errors(), 0)
|
||||
|
||||
// Assert Clients[0] Policy is set to the default, and the default doesn't override Clients[1]'s Policy.
|
||||
assert.Equal(t, config.OIDC.Clients[0].Policy, twoFactorPolicy)
|
||||
assert.Equal(t, config.OIDC.Clients[1].Policy, oneFactorPolicy)
|
||||
|
||||
// Assert Clients[0] Description is set to the Clients[0] ID, and Clients[1]'s Description is not overridden.
|
||||
assert.Equal(t, config.OIDC.Clients[0].ID, config.OIDC.Clients[0].Description)
|
||||
assert.Equal(t, "Normal Description", config.OIDC.Clients[1].Description)
|
||||
|
||||
// Assert Clients[0] ends up configured with the default Scopes.
|
||||
require.Len(t, config.OIDC.Clients[0].Scopes, 4)
|
||||
assert.Equal(t, "openid", config.OIDC.Clients[0].Scopes[0])
|
||||
assert.Equal(t, "groups", config.OIDC.Clients[0].Scopes[1])
|
||||
assert.Equal(t, "profile", config.OIDC.Clients[0].Scopes[2])
|
||||
assert.Equal(t, "email", config.OIDC.Clients[0].Scopes[3])
|
||||
|
||||
require.Len(t, config.OIDC.Clients[0].GrantTypes, 2)
|
||||
assert.Equal(t, "refresh_token", config.OIDC.Clients[0].GrantTypes[0])
|
||||
assert.Equal(t, "authorization_code", config.OIDC.Clients[0].GrantTypes[1])
|
||||
|
||||
require.Len(t, config.OIDC.Clients[0].ResponseTypes, 1)
|
||||
assert.Equal(t, "code", config.OIDC.Clients[0].ResponseTypes[0])
|
||||
|
||||
// Assert Clients[1] ends up configured with the configured Scopes and the openid Scope.
|
||||
require.Len(t, config.OIDC.Clients[1].Scopes, 2)
|
||||
assert.Equal(t, "groups", config.OIDC.Clients[1].Scopes[0])
|
||||
assert.Equal(t, "openid", config.OIDC.Clients[1].Scopes[1])
|
||||
|
||||
// Assert Clients[0] ends up configured with the default GrantTypes.
|
||||
require.Len(t, config.OIDC.Clients[0].GrantTypes, 2)
|
||||
assert.Equal(t, "refresh_token", config.OIDC.Clients[0].GrantTypes[0])
|
||||
assert.Equal(t, "authorization_code", config.OIDC.Clients[0].GrantTypes[1])
|
||||
|
||||
// Assert Clients[1] ends up configured with only the configured GrantTypes.
|
||||
require.Len(t, config.OIDC.Clients[1].GrantTypes, 1)
|
||||
assert.Equal(t, "refresh_token", config.OIDC.Clients[1].GrantTypes[0])
|
||||
|
||||
// Assert Clients[0] ends up configured with the default ResponseTypes.
|
||||
require.Len(t, config.OIDC.Clients[0].ResponseTypes, 1)
|
||||
assert.Equal(t, "code", config.OIDC.Clients[0].ResponseTypes[0])
|
||||
|
||||
// Assert Clients[1] ends up configured only with the configured ResponseTypes.
|
||||
require.Len(t, config.OIDC.Clients[1].ResponseTypes, 2)
|
||||
assert.Equal(t, "token", config.OIDC.Clients[1].ResponseTypes[0])
|
||||
assert.Equal(t, "code", config.OIDC.Clients[1].ResponseTypes[1])
|
||||
|
||||
// Assert Clients[0] ends up configured with the default ResponseModes.
|
||||
require.Len(t, config.OIDC.Clients[0].ResponseModes, 3)
|
||||
assert.Equal(t, "form_post", config.OIDC.Clients[0].ResponseModes[0])
|
||||
assert.Equal(t, "query", config.OIDC.Clients[0].ResponseModes[1])
|
||||
assert.Equal(t, "fragment", config.OIDC.Clients[0].ResponseModes[2])
|
||||
|
||||
// Assert Clients[1] ends up configured only with the configured ResponseModes.
|
||||
require.Len(t, config.OIDC.Clients[1].ResponseModes, 2)
|
||||
assert.Equal(t, "form_post", config.OIDC.Clients[1].ResponseModes[0])
|
||||
assert.Equal(t, "fragment", config.OIDC.Clients[1].ResponseModes[1])
|
||||
|
||||
assert.Equal(t, false, config.OIDC.EnableClientDebugMessages)
|
||||
assert.Equal(t, time.Hour, config.OIDC.AccessTokenLifespan)
|
||||
assert.Equal(t, time.Minute, config.OIDC.AuthorizeCodeLifespan)
|
||||
assert.Equal(t, time.Hour, config.OIDC.IDTokenLifespan)
|
||||
assert.Equal(t, time.Minute*90, config.OIDC.RefreshTokenLifespan)
|
||||
}
|
||||
|
|
|
@ -63,7 +63,6 @@ const msMaximumRandomDelay = int64(85)
|
|||
|
||||
// OIDC constants.
|
||||
const (
|
||||
oidcWellKnownPath = "/.well-known/openid-configuration"
|
||||
oidcJWKsPath = "/api/oidc/jwks"
|
||||
oidcAuthorizePath = "/api/oidc/authorize"
|
||||
oidcTokenPath = "/api/oidc/token" //nolint:gosec // This is not a hard coded credential, it's a path.
|
||||
|
@ -78,12 +77,3 @@ const (
|
|||
accept = "accept"
|
||||
reject = "reject"
|
||||
)
|
||||
|
||||
var scopeDescriptions = map[string]string{
|
||||
"openid": "Use OpenID to verify your identity",
|
||||
"email": "Access your email addresses",
|
||||
"profile": "Access your username",
|
||||
"groups": "Access your group membership",
|
||||
}
|
||||
|
||||
var audienceDescriptions = map[string]string{}
|
||||
|
|
|
@ -7,7 +7,6 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
"github.com/authelia/authelia/internal/regulation"
|
||||
"github.com/authelia/authelia/internal/session"
|
||||
|
@ -168,22 +167,13 @@ func FirstFactorPost(msInitialDelay time.Duration, delayEnabled bool) middleware
|
|||
|
||||
ctx.Logger.Tracef("Details for user %s => groups: %s, emails %s", bodyJSON.Username, userDetails.Groups, userDetails.Emails)
|
||||
|
||||
// And set those information in the new session.
|
||||
userSession.Username = userDetails.Username
|
||||
userSession.DisplayName = userDetails.DisplayName
|
||||
userSession.Groups = userDetails.Groups
|
||||
userSession.Emails = userDetails.Emails
|
||||
userSession.AuthenticationLevel = authentication.OneFactor
|
||||
userSession.LastActivity = time.Now().Unix()
|
||||
userSession.KeepMeLoggedIn = keepMeLoggedIn
|
||||
refresh, refreshInterval := getProfileRefreshSettings(ctx.Configuration.AuthenticationBackend)
|
||||
userSession.SetOneFactor(ctx.Clock.Now(), userDetails, keepMeLoggedIn)
|
||||
|
||||
if refresh {
|
||||
if refresh, refreshInterval := getProfileRefreshSettings(ctx.Configuration.AuthenticationBackend); refresh {
|
||||
userSession.RefreshTTL = ctx.Clock.Now().Add(refreshInterval)
|
||||
}
|
||||
|
||||
err = ctx.SaveSession(userSession)
|
||||
|
||||
if err != nil {
|
||||
handleAuthenticationUnauthorized(ctx, fmt.Errorf("Unable to save session of user %s", bodyJSON.Username), authenticationFailedMessage)
|
||||
return
|
||||
|
@ -192,7 +182,7 @@ func FirstFactorPost(msInitialDelay time.Duration, delayEnabled bool) middleware
|
|||
successful = true
|
||||
|
||||
if userSession.OIDCWorkflowSession != nil {
|
||||
HandleOIDCWorkflowResponse(ctx)
|
||||
handleOIDCWorkflowResponse(ctx)
|
||||
} else {
|
||||
Handle1FAResponse(ctx, bodyJSON.TargetURL, bodyJSON.RequestMethod, userSession.Username, userSession.Groups)
|
||||
}
|
||||
|
|
|
@ -4,8 +4,11 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"github.com/ory/fosite/token/jwt"
|
||||
|
||||
"github.com/authelia/authelia/internal/logging"
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
|
@ -13,6 +16,7 @@ import (
|
|||
"github.com/authelia/authelia/internal/session"
|
||||
)
|
||||
|
||||
//nolint: gocyclo // TODO: Consider refactoring time permitting.
|
||||
func oidcAuthorize(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *http.Request) {
|
||||
ar, err := ctx.Providers.OpenIDConnect.Fosite.NewAuthorizeRequest(ctx, r)
|
||||
if err != nil {
|
||||
|
@ -46,14 +50,34 @@ func oidcAuthorize(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *http
|
|||
return
|
||||
}
|
||||
|
||||
extraClaims := map[string]interface{}{}
|
||||
|
||||
for _, scope := range requestedScopes {
|
||||
ar.GrantScope(scope)
|
||||
|
||||
switch scope {
|
||||
case "groups":
|
||||
extraClaims["groups"] = userSession.Groups
|
||||
case "profile":
|
||||
extraClaims["name"] = userSession.DisplayName
|
||||
case "email":
|
||||
if len(userSession.Emails) != 0 {
|
||||
extraClaims["email"] = userSession.Emails[0]
|
||||
if len(userSession.Emails) > 1 {
|
||||
extraClaims["alt_emails"] = userSession.Emails[1:]
|
||||
}
|
||||
// TODO (james-d-elliott): actually verify emails and record that information.
|
||||
extraClaims["email_verified"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range requestedAudience {
|
||||
ar.GrantAudience(a)
|
||||
}
|
||||
|
||||
workflowCreated := time.Unix(userSession.OIDCWorkflowSession.CreatedTimestamp, 0)
|
||||
|
||||
userSession.OIDCWorkflowSession = nil
|
||||
if err := ctx.SaveSession(userSession); err != nil {
|
||||
ctx.Logger.Errorf("%v", err)
|
||||
|
@ -62,15 +86,41 @@ func oidcAuthorize(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *http
|
|||
return
|
||||
}
|
||||
|
||||
oauthSession, err := newOIDCSession(ctx, ar)
|
||||
issuer, err := ctx.ForwardedProtoHost()
|
||||
if err != nil {
|
||||
ctx.Logger.Errorf("Error occurred in NewOIDCSession: %+v", err)
|
||||
ctx.Logger.Errorf("Error occurred obtaining issuer: %+v", err)
|
||||
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, ar, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
response, err := ctx.Providers.OpenIDConnect.Fosite.NewAuthorizeResponse(ctx, ar, oauthSession)
|
||||
authTime, err := userSession.AuthenticatedTime(client.Policy)
|
||||
if err != nil {
|
||||
ctx.Logger.Errorf("Error occurred obtaining authentication timestamp: %+v", err)
|
||||
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, ar, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
response, err := ctx.Providers.OpenIDConnect.Fosite.NewAuthorizeResponse(ctx, ar, &oidc.OpenIDSession{
|
||||
DefaultSession: &openid.DefaultSession{
|
||||
Claims: &jwt.IDTokenClaims{
|
||||
Subject: userSession.Username,
|
||||
Issuer: issuer,
|
||||
AuthTime: authTime,
|
||||
RequestedAt: workflowCreated,
|
||||
IssuedAt: time.Now(),
|
||||
Nonce: ar.GetRequestForm().Get("nonce"),
|
||||
Audience: []string{ar.GetClient().GetID()},
|
||||
Extra: extraClaims,
|
||||
},
|
||||
Headers: &jwt.Headers{Extra: map[string]interface{}{
|
||||
"kid": ctx.Providers.OpenIDConnect.KeyManager.GetActiveKeyID(),
|
||||
}},
|
||||
Subject: userSession.Username,
|
||||
},
|
||||
ClientID: clientID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.Logger.Errorf("Error occurred in NewAuthorizeResponse: %+v", err)
|
||||
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, ar, err)
|
||||
|
@ -98,13 +148,15 @@ func oidcAuthorizeHandleAuthorizationOrConsentInsufficient(
|
|||
ctx.Logger.Debugf("User %s must consent with scopes %s",
|
||||
userSession.Username, strings.Join(ar.GetRequestedScopes(), ", "))
|
||||
|
||||
userSession.OIDCWorkflowSession = new(session.OIDCWorkflowSession)
|
||||
userSession.OIDCWorkflowSession.ClientID = client.ID
|
||||
userSession.OIDCWorkflowSession.RequestedScopes = ar.GetRequestedScopes()
|
||||
userSession.OIDCWorkflowSession.RequestedAudience = ar.GetRequestedAudience()
|
||||
userSession.OIDCWorkflowSession.AuthURI = redirectURL
|
||||
userSession.OIDCWorkflowSession.TargetURI = ar.GetRedirectURI().String()
|
||||
userSession.OIDCWorkflowSession.RequiredAuthorizationLevel = client.Policy
|
||||
userSession.OIDCWorkflowSession = &session.OIDCWorkflowSession{
|
||||
ClientID: client.ID,
|
||||
RequestedScopes: ar.GetRequestedScopes(),
|
||||
RequestedAudience: ar.GetRequestedAudience(),
|
||||
AuthURI: redirectURL,
|
||||
TargetURI: ar.GetRedirectURI().String(),
|
||||
RequiredAuthorizationLevel: client.Policy,
|
||||
CreatedTimestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := ctx.SaveSession(userSession); err != nil {
|
||||
ctx.Logger.Errorf("%v", err)
|
||||
|
|
|
@ -34,13 +34,7 @@ func oidcConsent(ctx *middlewares.AutheliaCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
var body ConsentGetResponseBody
|
||||
body.Scopes = scopeNamesToScopes(userSession.OIDCWorkflowSession.RequestedScopes)
|
||||
body.Audience = audienceNamesToAudience(userSession.OIDCWorkflowSession.RequestedAudience)
|
||||
body.ClientID = client.ID
|
||||
body.ClientDescription = client.Description
|
||||
|
||||
if err := ctx.SetJSONBody(body); err != nil {
|
||||
if err := ctx.SetJSONBody(client.GetConsentResponseBody(userSession.OIDCWorkflowSession)); err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to set JSON body: %v", err), "Operation failed")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,14 +7,7 @@ import (
|
|||
)
|
||||
|
||||
func oidcIntrospect(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, req *http.Request) {
|
||||
oidcSession, err := newDefaultOIDCSession(ctx)
|
||||
|
||||
if err != nil {
|
||||
ctx.Logger.Errorf("Error occurred in NewDefaultOIDCSession: %+v", err)
|
||||
ctx.Providers.OpenIDConnect.Fosite.WriteIntrospectionError(rw, err)
|
||||
|
||||
return
|
||||
}
|
||||
oidcSession := newOpenIDSession("")
|
||||
|
||||
ir, err := ctx.Providers.OpenIDConnect.Fosite.NewIntrospectionRequest(ctx, req, oidcSession)
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
func oidcJWKs(ctx *middlewares.AutheliaCtx) {
|
||||
ctx.SetContentType("application/json")
|
||||
|
||||
if err := json.NewEncoder(ctx).Encode(ctx.Providers.OpenIDConnect.GetKeySet()); err != nil {
|
||||
if err := json.NewEncoder(ctx).Encode(ctx.Providers.OpenIDConnect.KeyManager.GetKeySet()); err != nil {
|
||||
ctx.Error(err, "failed to serve jwk set")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,13 +9,7 @@ import (
|
|||
)
|
||||
|
||||
func oidcToken(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, req *http.Request) {
|
||||
oidcSession, err := newDefaultOIDCSession(ctx)
|
||||
if err != nil {
|
||||
ctx.Logger.Errorf("Error occurred in NewDefaultOIDCSession: %+v", err)
|
||||
ctx.Providers.OpenIDConnect.Fosite.WriteAccessError(rw, nil, err)
|
||||
|
||||
return
|
||||
}
|
||||
oidcSession := newOpenIDSession("")
|
||||
|
||||
accessRequest, accessReqErr := ctx.Providers.OpenIDConnect.Fosite.NewAccessRequest(ctx, req, oidcSession)
|
||||
if accessReqErr != nil {
|
||||
|
|
|
@ -7,11 +7,11 @@ import (
|
|||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
"github.com/authelia/authelia/internal/oidc"
|
||||
)
|
||||
|
||||
func oidcWellKnown(ctx *middlewares.AutheliaCtx) {
|
||||
var configuration WellKnownConfigurationJSON
|
||||
|
||||
// TODO (james-d-elliott): append the server.path here for path based installs. Also check other instances in OIDC.
|
||||
issuer, err := ctx.ForwardedProtoHost()
|
||||
if err != nil {
|
||||
ctx.Logger.Errorf("Error occurred in ForwardedProtoHost: %+v", err)
|
||||
|
@ -20,22 +20,42 @@ func oidcWellKnown(ctx *middlewares.AutheliaCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
configuration.Issuer = issuer
|
||||
configuration.AuthURL = fmt.Sprintf("%s%s", issuer, oidcAuthorizePath)
|
||||
configuration.TokenURL = fmt.Sprintf("%s%s", issuer, oidcTokenPath)
|
||||
configuration.RevocationEndpoint = fmt.Sprintf("%s%s", issuer, oidcRevokePath)
|
||||
configuration.JWKSURL = fmt.Sprintf("%s%s", issuer, oidcJWKsPath)
|
||||
configuration.Algorithms = []string{"RS256"}
|
||||
configuration.ScopesSupported = []string{
|
||||
wellKnown := oidc.WellKnownConfiguration{
|
||||
Issuer: issuer,
|
||||
JWKSURI: fmt.Sprintf("%s%s", issuer, oidcJWKsPath),
|
||||
|
||||
AuthorizationEndpoint: fmt.Sprintf("%s%s", issuer, oidcAuthorizePath),
|
||||
TokenEndpoint: fmt.Sprintf("%s%s", issuer, oidcTokenPath),
|
||||
RevocationEndpoint: fmt.Sprintf("%s%s", issuer, oidcRevokePath),
|
||||
|
||||
Algorithms: []string{"RS256"},
|
||||
|
||||
SubjectTypesSupported: []string{
|
||||
"public",
|
||||
},
|
||||
ResponseTypesSupported: []string{
|
||||
"code",
|
||||
"token",
|
||||
"id_token",
|
||||
"code token",
|
||||
"code id_token",
|
||||
"token id_token",
|
||||
"code token id_token",
|
||||
"none",
|
||||
},
|
||||
ResponseModesSupported: []string{
|
||||
"form_post",
|
||||
"query",
|
||||
"fragment",
|
||||
},
|
||||
ScopesSupported: []string{
|
||||
"openid",
|
||||
"offline_access",
|
||||
"profile",
|
||||
"groups",
|
||||
"email",
|
||||
// Determine if this is really mandatory knowing the RP can request for a refresh token through the authorize
|
||||
// endpoint anyway.
|
||||
"offline_access",
|
||||
}
|
||||
configuration.ClaimsSupported = []string{
|
||||
},
|
||||
ClaimsSupported: []string{
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
|
@ -47,26 +67,21 @@ func oidcWellKnown(ctx *middlewares.AutheliaCtx) {
|
|||
"nonce",
|
||||
"email",
|
||||
"email_verified",
|
||||
"alt_emails",
|
||||
"groups",
|
||||
"name",
|
||||
}
|
||||
configuration.SubjectTypesSupported = []string{
|
||||
"public",
|
||||
}
|
||||
configuration.ResponseTypesSupported = []string{
|
||||
"code",
|
||||
"token",
|
||||
"id_token",
|
||||
"code token",
|
||||
"code id_token",
|
||||
"token id_token",
|
||||
"code token id_token",
|
||||
"none",
|
||||
},
|
||||
|
||||
RequestURIParameterSupported: false,
|
||||
BackChannelLogoutSupported: false,
|
||||
FrontChannelLogoutSupported: false,
|
||||
BackChannelLogoutSessionSupported: false,
|
||||
FrontChannelLogoutSessionSupported: false,
|
||||
}
|
||||
|
||||
ctx.SetContentType("application/json")
|
||||
|
||||
if err := json.NewEncoder(ctx).Encode(configuration); err != nil {
|
||||
if err := json.NewEncoder(ctx).Encode(wellKnown); err != nil {
|
||||
ctx.Logger.Errorf("Error occurred in json Encode: %+v", err)
|
||||
// TODO: Determine if this is the appropriate error code here.
|
||||
ctx.Response.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/duo"
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
)
|
||||
|
@ -65,16 +64,16 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
|
|||
return
|
||||
}
|
||||
|
||||
userSession.AuthenticationLevel = authentication.TwoFactor
|
||||
err = ctx.SaveSession(userSession)
|
||||
userSession.SetTwoFactor(ctx.Clock.Now())
|
||||
|
||||
err = ctx.SaveSession(userSession)
|
||||
if err != nil {
|
||||
handleAuthenticationUnauthorized(ctx, fmt.Errorf("Unable to update authentication level with Duo: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if userSession.OIDCWorkflowSession != nil {
|
||||
HandleOIDCWorkflowResponse(ctx)
|
||||
handleOIDCWorkflowResponse(ctx)
|
||||
} else {
|
||||
Handle2FAResponse(ctx, requestBody.TargetURL)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ package handlers
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
)
|
||||
|
||||
|
@ -44,16 +43,16 @@ func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler
|
|||
return
|
||||
}
|
||||
|
||||
userSession.AuthenticationLevel = authentication.TwoFactor
|
||||
err = ctx.SaveSession(userSession)
|
||||
userSession.SetTwoFactor(ctx.Clock.Now())
|
||||
|
||||
err = ctx.SaveSession(userSession)
|
||||
if err != nil {
|
||||
handleAuthenticationUnauthorized(ctx, fmt.Errorf("Unable to update the authentication level with TOTP: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if userSession.OIDCWorkflowSession != nil {
|
||||
HandleOIDCWorkflowResponse(ctx)
|
||||
handleOIDCWorkflowResponse(ctx)
|
||||
} else {
|
||||
Handle2FAResponse(ctx, requestBody.TargetURL)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ package handlers
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
)
|
||||
|
||||
|
@ -47,16 +46,16 @@ func SecondFactorU2FSignPost(u2fVerifier U2FVerifier) middlewares.RequestHandler
|
|||
return
|
||||
}
|
||||
|
||||
userSession.AuthenticationLevel = authentication.TwoFactor
|
||||
err = ctx.SaveSession(userSession)
|
||||
userSession.SetTwoFactor(ctx.Clock.Now())
|
||||
|
||||
err = ctx.SaveSession(userSession)
|
||||
if err != nil {
|
||||
handleAuthenticationUnauthorized(ctx, fmt.Errorf("Unable to update authentication level with U2F: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if userSession.OIDCWorkflowSession != nil {
|
||||
HandleOIDCWorkflowResponse(ctx)
|
||||
handleOIDCWorkflowResponse(ctx)
|
||||
} else {
|
||||
Handle2FAResponse(ctx, requestBody.TargetURL)
|
||||
}
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"github.com/ory/fosite/token/jwt"
|
||||
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
"github.com/authelia/authelia/internal/oidc"
|
||||
"github.com/authelia/authelia/internal/session"
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
@ -23,87 +20,13 @@ func isConsentMissing(workflow *session.OIDCWorkflowSession, requestedScopes, re
|
|||
len(requestedAudience) > 0 && utils.IsStringSlicesDifferentFold(requestedAudience, workflow.GrantedAudience)
|
||||
}
|
||||
|
||||
func scopeNamesToScopes(scopeSlice []string) (scopes []Scope) {
|
||||
for _, name := range scopeSlice {
|
||||
if val, ok := scopeDescriptions[name]; ok {
|
||||
scopes = append(scopes, Scope{name, val})
|
||||
} else {
|
||||
scopes = append(scopes, Scope{name, name})
|
||||
}
|
||||
}
|
||||
|
||||
return scopes
|
||||
}
|
||||
|
||||
func audienceNamesToAudience(scopeSlice []string) (audience []Audience) {
|
||||
for _, name := range scopeSlice {
|
||||
if val, ok := audienceDescriptions[name]; ok {
|
||||
audience = append(audience, Audience{name, val})
|
||||
} else {
|
||||
audience = append(audience, Audience{name, name})
|
||||
}
|
||||
}
|
||||
|
||||
return audience
|
||||
}
|
||||
|
||||
func newOIDCSession(ctx *middlewares.AutheliaCtx, ar fosite.AuthorizeRequester) (session *openid.DefaultSession, err error) {
|
||||
userSession := ctx.GetSession()
|
||||
|
||||
scopes := ar.GetGrantedScopes()
|
||||
|
||||
extra := map[string]interface{}{}
|
||||
|
||||
if len(userSession.Emails) != 0 && scopes.Has("email") {
|
||||
extra["email"] = userSession.Emails[0]
|
||||
extra["email_verified"] = true
|
||||
}
|
||||
|
||||
if scopes.Has("groups") {
|
||||
extra["groups"] = userSession.Groups
|
||||
}
|
||||
|
||||
if scopes.Has("profile") {
|
||||
extra["name"] = userSession.DisplayName
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: Adjust auth backends to return more profile information.
|
||||
It's probably ideal to adjust the auth providers at this time to not store 'extra' information in the session
|
||||
storage, and instead create a memory only storage for them.
|
||||
This is a simple design, have a map with a key of username, and a struct with the relevant information.
|
||||
*/
|
||||
|
||||
oidcSession, err := newDefaultOIDCSession(ctx)
|
||||
if oidcSession == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oidcSession.Claims.Extra = extra
|
||||
oidcSession.Claims.Subject = userSession.Username
|
||||
oidcSession.Claims.Audience = ar.GetGrantedAudience()
|
||||
|
||||
return oidcSession, err
|
||||
}
|
||||
|
||||
func newDefaultOIDCSession(ctx *middlewares.AutheliaCtx) (session *openid.DefaultSession, err error) {
|
||||
issuer, err := ctx.ForwardedProtoHost()
|
||||
|
||||
return &openid.DefaultSession{
|
||||
Claims: &jwt.IDTokenClaims{
|
||||
Issuer: issuer,
|
||||
// TODO(c.michaud): make this configurable
|
||||
ExpiresAt: time.Now().Add(time.Hour * 6),
|
||||
IssuedAt: time.Now(),
|
||||
RequestedAt: time.Now(),
|
||||
AuthTime: time.Now(),
|
||||
Extra: make(map[string]interface{}),
|
||||
func newOpenIDSession(subject string) *oidc.OpenIDSession {
|
||||
return &oidc.OpenIDSession{
|
||||
DefaultSession: &openid.DefaultSession{
|
||||
Claims: new(jwt.IDTokenClaims),
|
||||
Headers: new(jwt.Headers),
|
||||
Subject: subject,
|
||||
},
|
||||
Headers: &jwt.Headers{
|
||||
Extra: map[string]interface{}{
|
||||
// TODO: Obtain this from the active keys when we implement key rotation.
|
||||
"kid": "main-key",
|
||||
},
|
||||
},
|
||||
}, err
|
||||
Extra: map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
// RegisterOIDC registers the handlers with the fasthttp *router.Router. TODO: Add paths for UserInfo, Flush, Logout.
|
||||
func RegisterOIDC(router *router.Router, middleware middlewares.RequestHandlerBridge) {
|
||||
// TODO: Add OPTIONS handler.
|
||||
router.GET(oidcWellKnownPath, middleware(oidcWellKnown))
|
||||
router.GET("/.well-known/openid-configuration", middleware(oidcWellKnown))
|
||||
|
||||
router.GET(oidcConsentPath, middleware(oidcConsent))
|
||||
|
|
@ -11,8 +11,8 @@ import (
|
|||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
// HandleOIDCWorkflowResponse handle the redirection upon authentication in the OIDC workflow.
|
||||
func HandleOIDCWorkflowResponse(ctx *middlewares.AutheliaCtx) {
|
||||
// handleOIDCWorkflowResponse handle the redirection upon authentication in the OIDC workflow.
|
||||
func handleOIDCWorkflowResponse(ctx *middlewares.AutheliaCtx) {
|
||||
userSession := ctx.GetSession()
|
||||
|
||||
if !authorization.IsAuthLevelSufficient(userSession.AuthenticationLevel, userSession.OIDCWorkflowSession.RequiredAuthorizationLevel) {
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt"
|
||||
)
|
||||
|
||||
// ConsentPostRequestBody schema of the request body of the consent POST endpoint.
|
||||
type ConsentPostRequestBody struct {
|
||||
ClientID string `json:"client_id"`
|
||||
|
@ -14,50 +10,3 @@ type ConsentPostRequestBody struct {
|
|||
type ConsentPostResponseBody struct {
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
// ConsentGetResponseBody schema of the response body of the consent GET endpoint.
|
||||
type ConsentGetResponseBody struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientDescription string `json:"client_description"`
|
||||
Scopes []Scope `json:"scopes"`
|
||||
Audience []Audience `json:"audience"`
|
||||
}
|
||||
|
||||
// Scope represents the scope information.
|
||||
type Scope struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Audience represents the audience information.
|
||||
type Audience struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// OIDCClaims represents a set of OIDC claims.
|
||||
type OIDCClaims struct {
|
||||
jwt.StandardClaims
|
||||
|
||||
Workflow string `json:"workflow"`
|
||||
Username string `json:"username,omitempty"`
|
||||
RequestedScopes []string `json:"requested_scopes,omitempty"`
|
||||
}
|
||||
|
||||
// WellKnownConfigurationJSON is the OIDC well known config struct.
|
||||
type WellKnownConfigurationJSON struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthURL string `json:"authorization_endpoint"`
|
||||
TokenURL string `json:"token_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
JWKSURL string `json:"jwks_uri"`
|
||||
Algorithms []string `json:"id_token_signing_alg_values_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
BackChannelLogoutSupported bool `json:"backchannel_logout_supported"`
|
||||
BackChannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported"`
|
||||
FrontChannelLogoutSupported bool `json:"frontchannel_logout_supported"`
|
||||
FrontChannelLogoutSessionSupported bool `json:"frontchannel_logout_session_supported"`
|
||||
}
|
||||
|
|
|
@ -5,20 +5,32 @@ import (
|
|||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/authorization"
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/internal/session"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
Policy authorization.Level `json:"-"`
|
||||
// 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),
|
||||
RedirectURIs: config.RedirectURIs,
|
||||
GrantTypes: config.GrantTypes,
|
||||
ResponseTypes: config.ResponseTypes,
|
||||
Scopes: config.Scopes,
|
||||
|
||||
ResponseModes: []fosite.ResponseModeType{
|
||||
fosite.ResponseModeDefault,
|
||||
},
|
||||
}
|
||||
|
||||
for _, mode := range config.ResponseModes {
|
||||
client.ResponseModes = append(client.ResponseModes, fosite.ResponseModeType(mode))
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// IsAuthenticationLevelSufficient returns if the provided authentication.Level is sufficient for the client of the AutheliaClient.
|
||||
|
@ -31,6 +43,21 @@ func (c InternalClient) GetID() string {
|
|||
return c.ID
|
||||
}
|
||||
|
||||
// GetConsentResponseBody returns the proper consent response body for this session.OIDCWorkflowSession.
|
||||
func (c InternalClient) GetConsentResponseBody(session *session.OIDCWorkflowSession) ConsentGetResponseBody {
|
||||
body := ConsentGetResponseBody{
|
||||
ClientID: c.ID,
|
||||
ClientDescription: c.Description,
|
||||
}
|
||||
|
||||
if session != nil {
|
||||
body.Scopes = scopeNamesToScopes(session.RequestedScopes)
|
||||
body.Audience = audienceNamesToAudience(session.RequestedAudience)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// GetHashedSecret returns the Secret.
|
||||
func (c InternalClient) GetHashedSecret() []byte {
|
||||
return c.Secret
|
||||
|
@ -73,3 +100,10 @@ func (c InternalClient) IsPublic() bool {
|
|||
func (c InternalClient) GetAudience() fosite.Arguments {
|
||||
return c.Audience
|
||||
}
|
||||
|
||||
// GetResponseModes returns the valid response modes for this client.
|
||||
//
|
||||
// Implements the fosite.ResponseModeClient.
|
||||
func (c InternalClient) GetResponseModes() []fosite.ResponseModeType {
|
||||
return c.ResponseModes
|
||||
}
|
||||
|
|
|
@ -3,12 +3,46 @@ package oidc
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/authorization"
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/internal/session"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
blankConfig := schema.OpenIDConnectClientConfiguration{}
|
||||
blankClient := NewClient(blankConfig)
|
||||
assert.Equal(t, "", blankClient.ID)
|
||||
assert.Equal(t, "", blankClient.Description)
|
||||
assert.Equal(t, "", blankClient.Description)
|
||||
require.Len(t, blankClient.ResponseModes, 1)
|
||||
assert.Equal(t, fosite.ResponseModeDefault, blankClient.ResponseModes[0])
|
||||
|
||||
exampleConfig := schema.OpenIDConnectClientConfiguration{
|
||||
ID: "myapp",
|
||||
Description: "My App",
|
||||
Policy: "two_factor",
|
||||
Secret: "abcdef",
|
||||
RedirectURIs: []string{"https://google.com/callback"},
|
||||
Scopes: schema.DefaultOpenIDConnectClientConfiguration.Scopes,
|
||||
ResponseTypes: schema.DefaultOpenIDConnectClientConfiguration.ResponseTypes,
|
||||
GrantTypes: schema.DefaultOpenIDConnectClientConfiguration.GrantTypes,
|
||||
ResponseModes: schema.DefaultOpenIDConnectClientConfiguration.ResponseModes,
|
||||
}
|
||||
|
||||
exampleClient := NewClient(exampleConfig)
|
||||
assert.Equal(t, "myapp", exampleClient.ID)
|
||||
require.Len(t, exampleClient.ResponseModes, 4)
|
||||
assert.Equal(t, fosite.ResponseModeDefault, exampleClient.ResponseModes[0])
|
||||
assert.Equal(t, fosite.ResponseModeFormPost, exampleClient.ResponseModes[1])
|
||||
assert.Equal(t, fosite.ResponseModeQuery, exampleClient.ResponseModes[2])
|
||||
assert.Equal(t, fosite.ResponseModeFragment, exampleClient.ResponseModes[3])
|
||||
}
|
||||
|
||||
func TestIsAuthenticationLevelSufficient(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
|
@ -32,3 +66,154 @@ func TestIsAuthenticationLevelSufficient(t *testing.T) {
|
|||
assert.False(t, c.IsAuthenticationLevelSufficient(authentication.OneFactor))
|
||||
assert.False(t, c.IsAuthenticationLevelSufficient(authentication.TwoFactor))
|
||||
}
|
||||
|
||||
func TestInternalClient_GetConsentResponseBody(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
consentRequestBody := c.GetConsentResponseBody(nil)
|
||||
assert.Equal(t, "", consentRequestBody.ClientID)
|
||||
assert.Equal(t, "", consentRequestBody.ClientDescription)
|
||||
assert.Equal(t, []Scope(nil), consentRequestBody.Scopes)
|
||||
assert.Equal(t, []Audience(nil), consentRequestBody.Audience)
|
||||
|
||||
c.ID = "myclient"
|
||||
c.Description = "My Client"
|
||||
|
||||
workflow := &session.OIDCWorkflowSession{
|
||||
RequestedAudience: []string{"https://example.com"},
|
||||
RequestedScopes: []string{"openid", "groups"},
|
||||
}
|
||||
expectedScopes := []Scope{
|
||||
{"openid", "Use OpenID to verify your identity"},
|
||||
{"groups", "Access your group membership"},
|
||||
}
|
||||
expectedAudiences := []Audience{
|
||||
{"https://example.com", "https://example.com"},
|
||||
}
|
||||
|
||||
consentRequestBody = c.GetConsentResponseBody(workflow)
|
||||
assert.Equal(t, "myclient", consentRequestBody.ClientID)
|
||||
assert.Equal(t, "My Client", consentRequestBody.ClientDescription)
|
||||
assert.Equal(t, expectedScopes, consentRequestBody.Scopes)
|
||||
assert.Equal(t, expectedAudiences, consentRequestBody.Audience)
|
||||
}
|
||||
|
||||
func TestInternalClient_GetAudience(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
audience := c.GetAudience()
|
||||
assert.Len(t, audience, 0)
|
||||
|
||||
c.Audience = []string{"https://example.com"}
|
||||
|
||||
audience = c.GetAudience()
|
||||
require.Len(t, audience, 1)
|
||||
assert.Equal(t, "https://example.com", audience[0])
|
||||
}
|
||||
|
||||
func TestInternalClient_GetScopes(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
scopes := c.GetScopes()
|
||||
assert.Len(t, scopes, 0)
|
||||
|
||||
c.Scopes = []string{"openid"}
|
||||
|
||||
scopes = c.GetScopes()
|
||||
require.Len(t, scopes, 1)
|
||||
assert.Equal(t, "openid", scopes[0])
|
||||
}
|
||||
|
||||
func TestInternalClient_GetGrantTypes(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
grantTypes := c.GetGrantTypes()
|
||||
require.Len(t, grantTypes, 1)
|
||||
assert.Equal(t, "authorization_code", grantTypes[0])
|
||||
|
||||
c.GrantTypes = []string{"device_code"}
|
||||
|
||||
grantTypes = c.GetGrantTypes()
|
||||
require.Len(t, grantTypes, 1)
|
||||
assert.Equal(t, "device_code", grantTypes[0])
|
||||
}
|
||||
|
||||
func TestInternalClient_GetHashedSecret(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
hashedSecret := c.GetHashedSecret()
|
||||
assert.Equal(t, []byte(nil), hashedSecret)
|
||||
|
||||
c.Secret = []byte("a_bad_secret")
|
||||
|
||||
hashedSecret = c.GetHashedSecret()
|
||||
assert.Equal(t, []byte("a_bad_secret"), hashedSecret)
|
||||
}
|
||||
|
||||
func TestInternalClient_GetID(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
id := c.GetID()
|
||||
assert.Equal(t, "", id)
|
||||
|
||||
c.ID = "myid"
|
||||
|
||||
id = c.GetID()
|
||||
assert.Equal(t, "myid", id)
|
||||
}
|
||||
|
||||
func TestInternalClient_GetRedirectURIs(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
redirectURIs := c.GetRedirectURIs()
|
||||
require.Len(t, redirectURIs, 0)
|
||||
|
||||
c.RedirectURIs = []string{"https://example.com/oauth2/callback"}
|
||||
|
||||
redirectURIs = c.GetRedirectURIs()
|
||||
require.Len(t, redirectURIs, 1)
|
||||
assert.Equal(t, "https://example.com/oauth2/callback", redirectURIs[0])
|
||||
}
|
||||
|
||||
func TestInternalClient_GetResponseModes(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
responseModes := c.GetResponseModes()
|
||||
require.Len(t, responseModes, 0)
|
||||
|
||||
c.ResponseModes = []fosite.ResponseModeType{
|
||||
fosite.ResponseModeDefault, fosite.ResponseModeFormPost,
|
||||
fosite.ResponseModeQuery, fosite.ResponseModeFragment,
|
||||
}
|
||||
|
||||
responseModes = c.GetResponseModes()
|
||||
require.Len(t, responseModes, 4)
|
||||
assert.Equal(t, fosite.ResponseModeDefault, responseModes[0])
|
||||
assert.Equal(t, fosite.ResponseModeFormPost, responseModes[1])
|
||||
assert.Equal(t, fosite.ResponseModeQuery, responseModes[2])
|
||||
assert.Equal(t, fosite.ResponseModeFragment, responseModes[3])
|
||||
}
|
||||
|
||||
func TestInternalClient_GetResponseTypes(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
responseTypes := c.GetResponseTypes()
|
||||
require.Len(t, responseTypes, 1)
|
||||
assert.Equal(t, "code", responseTypes[0])
|
||||
|
||||
c.ResponseTypes = []string{"code", "id_token"}
|
||||
|
||||
responseTypes = c.GetResponseTypes()
|
||||
require.Len(t, responseTypes, 2)
|
||||
assert.Equal(t, "code", responseTypes[0])
|
||||
assert.Equal(t, "id_token", responseTypes[1])
|
||||
}
|
||||
|
||||
func TestInternalClient_IsPublic(t *testing.T) {
|
||||
c := InternalClient{}
|
||||
|
||||
assert.False(t, c.IsPublic())
|
||||
|
||||
c.Public = true
|
||||
assert.True(t, c.IsPublic())
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package oidc
|
||||
|
||||
var scopeDescriptions = map[string]string{
|
||||
"openid": "Use OpenID to verify your identity",
|
||||
"email": "Access your email addresses",
|
||||
"profile": "Access your display name",
|
||||
"groups": "Access your group membership",
|
||||
}
|
||||
|
||||
var audienceDescriptions = map[string]string{}
|
|
@ -5,12 +5,8 @@ import (
|
|||
"crypto/subtle"
|
||||
)
|
||||
|
||||
// AutheliaHasher implements the fosite.Hasher interface without an actual hashing algo.
|
||||
type AutheliaHasher struct {
|
||||
}
|
||||
|
||||
// Compare compares the hash with the data and returns an error if they don't match.
|
||||
func (h AutheliaHasher) Compare(ctx context.Context, hash, data []byte) (err error) {
|
||||
func (h AutheliaHasher) Compare(_ context.Context, hash, data []byte) (err error) {
|
||||
if subtle.ConstantTimeCompare(hash, data) == 0 {
|
||||
return errPasswordsDoNotMatch
|
||||
}
|
||||
|
@ -19,6 +15,6 @@ func (h AutheliaHasher) Compare(ctx context.Context, hash, data []byte) (err err
|
|||
}
|
||||
|
||||
// Hash creates a new hash from data.
|
||||
func (h AutheliaHasher) Hash(ctx context.Context, data []byte) (hash []byte, err error) {
|
||||
func (h AutheliaHasher) Hash(_ context.Context, data []byte) (hash []byte, err error) {
|
||||
return data, nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package oidc
|
||||
|
||||
func scopeNamesToScopes(scopeSlice []string) (scopes []Scope) {
|
||||
for _, name := range scopeSlice {
|
||||
if val, ok := scopeDescriptions[name]; ok {
|
||||
scopes = append(scopes, Scope{name, val})
|
||||
} else {
|
||||
scopes = append(scopes, Scope{name, name})
|
||||
}
|
||||
}
|
||||
|
||||
return scopes
|
||||
}
|
||||
|
||||
func audienceNamesToAudience(scopeSlice []string) (audience []Audience) {
|
||||
for _, name := range scopeSlice {
|
||||
if val, ok := audienceDescriptions[name]; ok {
|
||||
audience = append(audience, Audience{name, val})
|
||||
} else {
|
||||
audience = append(audience, Audience{name, name})
|
||||
}
|
||||
}
|
||||
|
||||
return audience
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestScopeNamesToScopes(t *testing.T) {
|
||||
scopeNames := []string{"openid"}
|
||||
|
||||
scopes := scopeNamesToScopes(scopeNames)
|
||||
assert.Equal(t, "openid", scopes[0].Name)
|
||||
assert.Equal(t, "Use OpenID to verify your identity", scopes[0].Description)
|
||||
|
||||
scopeNames = []string{"groups"}
|
||||
|
||||
scopes = scopeNamesToScopes(scopeNames)
|
||||
assert.Equal(t, "groups", scopes[0].Name)
|
||||
assert.Equal(t, "Access your group membership", scopes[0].Description)
|
||||
|
||||
scopeNames = []string{"profile"}
|
||||
|
||||
scopes = scopeNamesToScopes(scopeNames)
|
||||
assert.Equal(t, "profile", scopes[0].Name)
|
||||
assert.Equal(t, "Access your display name", scopes[0].Description)
|
||||
|
||||
scopeNames = []string{"email"}
|
||||
|
||||
scopes = scopeNamesToScopes(scopeNames)
|
||||
assert.Equal(t, "email", scopes[0].Name)
|
||||
assert.Equal(t, "Access your email addresses", scopes[0].Description)
|
||||
|
||||
scopeNames = []string{"another"}
|
||||
|
||||
scopes = scopeNamesToScopes(scopeNames)
|
||||
assert.Equal(t, "another", scopes[0].Name)
|
||||
assert.Equal(t, "another", scopes[0].Description)
|
||||
}
|
||||
|
||||
func TestAudienceNamesToScopes(t *testing.T) {
|
||||
audienceNames := []string{"audience", "another_aud"}
|
||||
|
||||
audiences := audienceNamesToAudience(audienceNames)
|
||||
assert.Equal(t, "audience", audiences[0].Name)
|
||||
assert.Equal(t, "audience", audiences[0].Description)
|
||||
assert.Equal(t, "another_aud", audiences[1].Name)
|
||||
assert.Equal(t, "another_aud", audiences[1].Description)
|
||||
}
|
|
@ -0,0 +1,197 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/fosite/token/jwt"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
// NewKeyManagerWithConfiguration when provided a schema.OpenIDConnectConfiguration creates a new KeyManager and adds an
|
||||
// initial key to the manager.
|
||||
func NewKeyManagerWithConfiguration(configuration *schema.OpenIDConnectConfiguration) (manager *KeyManager, err error) {
|
||||
manager = NewKeyManager()
|
||||
|
||||
_, _, err = manager.AddActivePrivateKeyData(configuration.IssuerPrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// NewKeyManager creates a new empty KeyManager.
|
||||
func NewKeyManager() (manager *KeyManager) {
|
||||
manager = new(KeyManager)
|
||||
manager.keys = map[string]*rsa.PrivateKey{}
|
||||
manager.keySet = new(jose.JSONWebKeySet)
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
// Strategy returns the RS256JWTStrategy.
|
||||
func (m KeyManager) Strategy() (strategy *RS256JWTStrategy) {
|
||||
return m.strategy
|
||||
}
|
||||
|
||||
// GetKeySet returns the joseJSONWebKeySet containing the rsa.PublicKey types.
|
||||
func (m KeyManager) GetKeySet() (keySet *jose.JSONWebKeySet) {
|
||||
return m.keySet
|
||||
}
|
||||
|
||||
// GetActiveWebKey obtains the currently active jose.JSONWebKey.
|
||||
func (m KeyManager) GetActiveWebKey() (webKey *jose.JSONWebKey, err error) {
|
||||
webKeys := m.keySet.Key(m.activeKeyID)
|
||||
if len(webKeys) == 1 {
|
||||
return &webKeys[0], nil
|
||||
}
|
||||
|
||||
if len(webKeys) == 0 {
|
||||
return nil, errors.New("could not find a key with the active key id")
|
||||
}
|
||||
|
||||
return &webKeys[0], errors.New("multiple keys with the same key id")
|
||||
}
|
||||
|
||||
// GetActiveKeyID returns the key id of the currently active key.
|
||||
func (m KeyManager) GetActiveKeyID() (keyID string) {
|
||||
return m.activeKeyID
|
||||
}
|
||||
|
||||
// GetActiveKey returns the rsa.PublicKey of the currently active key.
|
||||
func (m KeyManager) GetActiveKey() (key *rsa.PublicKey, err error) {
|
||||
if key, ok := m.keys[m.activeKeyID]; ok {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("failed to retrieve active public key")
|
||||
}
|
||||
|
||||
// GetActivePrivateKey returns the rsa.PrivateKey of the currently active key.
|
||||
func (m KeyManager) GetActivePrivateKey() (key *rsa.PrivateKey, err error) {
|
||||
if key, ok := m.keys[m.activeKeyID]; ok {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("failed to retrieve active private key")
|
||||
}
|
||||
|
||||
// AddActivePrivateKeyData adds a rsa.PublicKey given the key in the PEM string format, then sets it to the active key.
|
||||
func (m *KeyManager) AddActivePrivateKeyData(data string) (key *rsa.PrivateKey, webKey *jose.JSONWebKey, err error) {
|
||||
key, err = utils.ParseRsaPrivateKeyFromPemStr(data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
webKey, err = m.AddActivePrivateKey(key)
|
||||
|
||||
return key, webKey, err
|
||||
}
|
||||
|
||||
// AddActivePrivateKey adds a rsa.PublicKey, then sets it to the active key.
|
||||
func (m *KeyManager) AddActivePrivateKey(key *rsa.PrivateKey) (webKey *jose.JSONWebKey, err error) {
|
||||
wk := jose.JSONWebKey{
|
||||
Key: &key.PublicKey,
|
||||
Algorithm: "RS256",
|
||||
Use: "sig",
|
||||
}
|
||||
|
||||
keyID, err := wk.Thumbprint(crypto.SHA1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
strKeyID := strings.ToLower(fmt.Sprintf("%x", keyID))
|
||||
if len(strKeyID) >= 7 {
|
||||
// Shorten the key if it's greater than 7 to a length of exactly 7.
|
||||
strKeyID = strKeyID[0:6]
|
||||
}
|
||||
|
||||
if _, ok := m.keys[strKeyID]; ok {
|
||||
return nil, fmt.Errorf("key id %s already exists", strKeyID)
|
||||
}
|
||||
|
||||
// TODO: Add Mutex here when implementing key rotation.
|
||||
wk.KeyID = strKeyID
|
||||
m.keySet.Keys = append(m.keySet.Keys, wk)
|
||||
m.keys[strKeyID] = key
|
||||
m.activeKeyID = strKeyID
|
||||
|
||||
m.strategy, err = NewRS256JWTStrategy(wk.KeyID, key)
|
||||
if err != nil {
|
||||
return &wk, err
|
||||
}
|
||||
|
||||
return &wk, nil
|
||||
}
|
||||
|
||||
// NewRS256JWTStrategy returns a new RS256JWTStrategy.
|
||||
func NewRS256JWTStrategy(id string, key *rsa.PrivateKey) (strategy *RS256JWTStrategy, err error) {
|
||||
strategy = new(RS256JWTStrategy)
|
||||
strategy.JWTStrategy = new(jwt.RS256JWTStrategy)
|
||||
|
||||
strategy.SetKey(id, key)
|
||||
|
||||
return strategy, nil
|
||||
}
|
||||
|
||||
// RS256JWTStrategy is a decorator struct for the fosite RS256JWTStrategy.
|
||||
type RS256JWTStrategy struct {
|
||||
JWTStrategy *jwt.RS256JWTStrategy
|
||||
|
||||
keyID string
|
||||
}
|
||||
|
||||
// KeyID returns the key id.
|
||||
func (s RS256JWTStrategy) KeyID() (id string) {
|
||||
return s.keyID
|
||||
}
|
||||
|
||||
// SetKey sets the provided key id and key as the active key (this is what triggers fosite to use it).
|
||||
func (s *RS256JWTStrategy) SetKey(id string, key *rsa.PrivateKey) {
|
||||
s.keyID = id
|
||||
s.JWTStrategy.PrivateKey = key
|
||||
}
|
||||
|
||||
// Hash is a decorator func for the underlying fosite RS256JWTStrategy.
|
||||
func (s *RS256JWTStrategy) Hash(ctx context.Context, in []byte) ([]byte, error) {
|
||||
return s.JWTStrategy.Hash(ctx, in)
|
||||
}
|
||||
|
||||
// GetSigningMethodLength is a decorator func for the underlying fosite RS256JWTStrategy.
|
||||
func (s *RS256JWTStrategy) GetSigningMethodLength() int {
|
||||
return s.JWTStrategy.GetSigningMethodLength()
|
||||
}
|
||||
|
||||
// GetSignature is a decorator func for the underlying fosite RS256JWTStrategy.
|
||||
func (s *RS256JWTStrategy) GetSignature(ctx context.Context, token string) (string, error) {
|
||||
return s.JWTStrategy.GetSignature(ctx, token)
|
||||
}
|
||||
|
||||
// Generate is a decorator func for the underlying fosite RS256JWTStrategy.
|
||||
func (s *RS256JWTStrategy) Generate(ctx context.Context, claims jwt.MapClaims, header jwt.Mapper) (string, string, error) {
|
||||
return s.JWTStrategy.Generate(ctx, claims, header)
|
||||
}
|
||||
|
||||
// Validate is a decorator func for the underlying fosite RS256JWTStrategy.
|
||||
func (s *RS256JWTStrategy) Validate(ctx context.Context, token string) (string, error) {
|
||||
return s.JWTStrategy.Validate(ctx, token)
|
||||
}
|
||||
|
||||
// Decode is a decorator func for the underlying fosite RS256JWTStrategy.
|
||||
func (s *RS256JWTStrategy) Decode(ctx context.Context, token string) (*jwt.Token, error) {
|
||||
return s.JWTStrategy.Decode(ctx, token)
|
||||
}
|
||||
|
||||
// GetPublicKeyID is a decorator func for the underlying fosite RS256JWTStrategy.
|
||||
func (s *RS256JWTStrategy) GetPublicKeyID(_ context.Context) (string, error) {
|
||||
return s.keyID, nil
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKeyManager_AddActiveKeyData(t *testing.T) {
|
||||
manager := NewKeyManager()
|
||||
assert.Nil(t, manager.strategy)
|
||||
assert.Nil(t, manager.Strategy())
|
||||
|
||||
key, wk, err := manager.AddActivePrivateKeyData(exampleIssuerPrivateKey)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, key)
|
||||
require.NotNil(t, wk)
|
||||
require.NotNil(t, manager.strategy)
|
||||
require.NotNil(t, manager.Strategy())
|
||||
|
||||
thumbprint, err := wk.Thumbprint(crypto.SHA1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
kid := strings.ToLower(fmt.Sprintf("%x", thumbprint)[0:6])
|
||||
assert.Equal(t, manager.activeKeyID, kid)
|
||||
assert.Equal(t, kid, wk.KeyID)
|
||||
assert.Len(t, manager.keys, 1)
|
||||
assert.Len(t, manager.keySet.Keys, 1)
|
||||
assert.Contains(t, manager.keys, kid)
|
||||
|
||||
keys := manager.keySet.Key(kid)
|
||||
assert.Equal(t, keys[0].KeyID, kid)
|
||||
|
||||
privKey, err := manager.GetActivePrivateKey()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, privKey)
|
||||
|
||||
pubKey, err := manager.GetActiveKey()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, pubKey)
|
||||
|
||||
webKey, err := manager.GetActiveWebKey()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, webKey)
|
||||
|
||||
keySet := manager.GetKeySet()
|
||||
assert.NotNil(t, keySet)
|
||||
assert.Equal(t, kid, manager.GetActiveKeyID())
|
||||
}
|
|
@ -1,26 +1,12 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/compose"
|
||||
"github.com/ory/fosite/token/jwt"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
// OpenIDConnectProvider for OpenID Connect.
|
||||
type OpenIDConnectProvider struct {
|
||||
privateKeys map[string]*rsa.PrivateKey
|
||||
|
||||
Fosite fosite.OAuth2Provider
|
||||
Store *OpenIDConnectStore
|
||||
}
|
||||
|
||||
// NewOpenIDConnectProvider new-ups a OpenIDConnectProvider.
|
||||
func NewOpenIDConnectProvider(configuration *schema.OpenIDConnectConfiguration) (provider OpenIDConnectProvider, err error) {
|
||||
provider = OpenIDConnectProvider{
|
||||
|
@ -31,20 +17,31 @@ func NewOpenIDConnectProvider(configuration *schema.OpenIDConnectConfiguration)
|
|||
return provider, nil
|
||||
}
|
||||
|
||||
provider.Store = NewOpenIDConnectStore(configuration)
|
||||
|
||||
composeConfiguration := new(compose.Config)
|
||||
|
||||
key, err := utils.ParseRsaPrivateKeyFromPemStr(configuration.IssuerPrivateKey)
|
||||
provider.Store, err = NewOpenIDConnectStore(configuration)
|
||||
if err != nil {
|
||||
return provider, fmt.Errorf("unable to parse the private key of the OpenID issuer: %w", err)
|
||||
return provider, err
|
||||
}
|
||||
|
||||
provider.privateKeys = make(map[string]*rsa.PrivateKey)
|
||||
provider.privateKeys["main-key"] = key
|
||||
composeConfiguration := &compose.Config{
|
||||
AccessTokenLifespan: configuration.AccessTokenLifespan,
|
||||
AuthorizeCodeLifespan: configuration.AuthorizeCodeLifespan,
|
||||
IDTokenLifespan: configuration.IDTokenLifespan,
|
||||
RefreshTokenLifespan: configuration.RefreshTokenLifespan,
|
||||
SendDebugMessagesToClients: configuration.EnableClientDebugMessages,
|
||||
MinParameterEntropy: configuration.MinimumParameterEntropy,
|
||||
}
|
||||
|
||||
// TODO: Consider implementing RS512 as well.
|
||||
jwtStrategy := &jwt.RS256JWTStrategy{PrivateKey: key}
|
||||
keyManager, err := NewKeyManagerWithConfiguration(configuration)
|
||||
if err != nil {
|
||||
return provider, err
|
||||
}
|
||||
|
||||
provider.KeyManager = keyManager
|
||||
|
||||
key, err := provider.KeyManager.GetActivePrivateKey()
|
||||
if err != nil {
|
||||
return provider, err
|
||||
}
|
||||
|
||||
strategy := &compose.CommonStrategy{
|
||||
CoreStrategy: compose.NewOAuth2HMACStrategy(
|
||||
|
@ -54,9 +51,9 @@ func NewOpenIDConnectProvider(configuration *schema.OpenIDConnectConfiguration)
|
|||
),
|
||||
OpenIDConnectTokenStrategy: compose.NewOpenIDConnectStrategy(
|
||||
composeConfiguration,
|
||||
provider.privateKeys["main-key"],
|
||||
key,
|
||||
),
|
||||
JWTStrategy: jwtStrategy,
|
||||
JWTStrategy: provider.KeyManager.Strategy(),
|
||||
}
|
||||
|
||||
provider.Fosite = compose.Compose(
|
||||
|
@ -90,19 +87,3 @@ func NewOpenIDConnectProvider(configuration *schema.OpenIDConnectConfiguration)
|
|||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// GetKeySet returns the jose.JSONWebKeySet for the OpenIDConnectProvider.
|
||||
func (p OpenIDConnectProvider) GetKeySet() (webKeySet jose.JSONWebKeySet) {
|
||||
for keyID, key := range p.privateKeys {
|
||||
webKey := jose.JSONWebKey{
|
||||
Key: &key.PublicKey,
|
||||
KeyID: keyID,
|
||||
Algorithm: "RS256",
|
||||
Use: "sig",
|
||||
}
|
||||
|
||||
webKeySet.Keys = append(webKeySet.Keys, webKey)
|
||||
}
|
||||
|
||||
return webKeySet
|
||||
}
|
||||
|
|
|
@ -26,15 +26,41 @@ func TestOpenIDConnectProvider_NewOpenIDConnectProvider_BadIssuerKey(t *testing.
|
|||
assert.Error(t, err, "abc")
|
||||
}
|
||||
|
||||
func TestOpenIDConnectProvider_GetKeySet(t *testing.T) {
|
||||
p, err := NewOpenIDConnectProvider(&schema.OpenIDConnectConfiguration{
|
||||
func TestOpenIDConnectProvider_NewOpenIDConnectProvider_GoodConfiguration(t *testing.T) {
|
||||
provider, err := NewOpenIDConnectProvider(&schema.OpenIDConnectConfiguration{
|
||||
IssuerPrivateKey: exampleIssuerPrivateKey,
|
||||
HMACSecret: "asbdhaaskmdlkamdklasmdlkams",
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{
|
||||
{
|
||||
ID: "a-client",
|
||||
Secret: "a-client-secret",
|
||||
Policy: "one_factor",
|
||||
RedirectURIs: []string{
|
||||
"https://google.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "b-client",
|
||||
Description: "Normal Description",
|
||||
Secret: "b-client-secret",
|
||||
Policy: "two_factor",
|
||||
RedirectURIs: []string{
|
||||
"https://google.com",
|
||||
},
|
||||
Scopes: []string{
|
||||
"groups",
|
||||
},
|
||||
GrantTypes: []string{
|
||||
"refresh_token",
|
||||
},
|
||||
ResponseTypes: []string{
|
||||
"token",
|
||||
"code",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.NotNil(t, provider)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, p.GetKeySet().Keys, 1)
|
||||
assert.Equal(t, "RS256", p.GetKeySet().Keys[0].Algorithm)
|
||||
assert.Equal(t, "sig", p.GetKeySet().Keys[0].Use)
|
||||
assert.Equal(t, "main-key", p.GetKeySet().Keys[0].KeyID)
|
||||
}
|
||||
|
|
|
@ -14,31 +14,10 @@ import (
|
|||
)
|
||||
|
||||
// NewOpenIDConnectStore returns a new OpenIDConnectStore using the provided schema.OpenIDConnectConfiguration.
|
||||
func NewOpenIDConnectStore(configuration *schema.OpenIDConnectConfiguration) (store *OpenIDConnectStore) {
|
||||
store = &OpenIDConnectStore{}
|
||||
|
||||
store.clients = make(map[string]*InternalClient)
|
||||
|
||||
for _, clientConf := range configuration.Clients {
|
||||
policy := authorization.PolicyToLevel(clientConf.Policy)
|
||||
logging.Logger().Debugf("Registering client %s with policy %s (%v)", clientConf.ID, clientConf.Policy, policy)
|
||||
|
||||
client := &InternalClient{
|
||||
ID: clientConf.ID,
|
||||
Description: clientConf.Description,
|
||||
Policy: authorization.PolicyToLevel(clientConf.Policy),
|
||||
Secret: []byte(clientConf.Secret),
|
||||
RedirectURIs: clientConf.RedirectURIs,
|
||||
GrantTypes: clientConf.GrantTypes,
|
||||
ResponseTypes: clientConf.ResponseTypes,
|
||||
Scopes: clientConf.Scopes,
|
||||
}
|
||||
|
||||
store.clients[client.ID] = client
|
||||
}
|
||||
|
||||
store.memory = &storage.MemoryStore{
|
||||
IDSessions: make(map[string]fosite.Requester),
|
||||
func NewOpenIDConnectStore(configuration *schema.OpenIDConnectConfiguration) (store *OpenIDConnectStore, err error) {
|
||||
store = &OpenIDConnectStore{
|
||||
memory: &storage.MemoryStore{
|
||||
IDSessions: map[string]fosite.Requester{},
|
||||
Users: map[string]storage.MemoryUserRelation{},
|
||||
AuthorizeCodes: map[string]storage.StoreAuthorizeCode{},
|
||||
AccessTokens: map[string]fosite.Requester{},
|
||||
|
@ -46,19 +25,19 @@ func NewOpenIDConnectStore(configuration *schema.OpenIDConnectConfiguration) (st
|
|||
PKCES: map[string]fosite.Requester{},
|
||||
AccessTokenRequestIDs: map[string]string{},
|
||||
RefreshTokenRequestIDs: map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
store.clients = make(map[string]*InternalClient)
|
||||
|
||||
// OpenIDConnectStore is Authelia's internal representation of the fosite.Storage interface.
|
||||
//
|
||||
// Currently it is mostly just implementing a decorator pattern other then GetInternalClient.
|
||||
// The long term plan is to have these methods interact with the Authelia storage and
|
||||
// session providers where applicable.
|
||||
type OpenIDConnectStore struct {
|
||||
clients map[string]*InternalClient
|
||||
memory *storage.MemoryStore
|
||||
for _, client := range configuration.Clients {
|
||||
policy := authorization.PolicyToLevel(client.Policy)
|
||||
logging.Logger().Debugf("Registering client %s with policy %s (%v)", client.ID, client.Policy, policy)
|
||||
|
||||
store.clients[client.ID] = NewClient(client)
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// GetClientPolicy retrieves the policy from the client with the matching provided id.
|
||||
|
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
func TestOpenIDConnectStore_GetClientPolicy(t *testing.T) {
|
||||
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
s, err := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
IssuerPrivateKey: exampleIssuerPrivateKey,
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{
|
||||
{
|
||||
|
@ -32,6 +32,8 @@ func TestOpenIDConnectStore_GetClientPolicy(t *testing.T) {
|
|||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
policyOne := s.GetClientPolicy("myclient")
|
||||
assert.Equal(t, authorization.OneFactor, policyOne)
|
||||
|
||||
|
@ -43,7 +45,7 @@ func TestOpenIDConnectStore_GetClientPolicy(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestOpenIDConnectStore_GetInternalClient(t *testing.T) {
|
||||
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
s, err := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
IssuerPrivateKey: exampleIssuerPrivateKey,
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{
|
||||
{
|
||||
|
@ -56,6 +58,8 @@ func TestOpenIDConnectStore_GetInternalClient(t *testing.T) {
|
|||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
client, err := s.GetClient(context.Background(), "myinvalidclient")
|
||||
assert.EqualError(t, err, "not_found")
|
||||
assert.Nil(t, client)
|
||||
|
@ -74,11 +78,13 @@ func TestOpenIDConnectStore_GetInternalClient_ValidClient(t *testing.T) {
|
|||
Scopes: []string{"openid", "profile"},
|
||||
Secret: "mysecret",
|
||||
}
|
||||
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
s, err := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
IssuerPrivateKey: exampleIssuerPrivateKey,
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{c1},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
client, err := s.GetInternalClient(c1.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
|
@ -100,18 +106,20 @@ func TestOpenIDConnectStore_GetInternalClient_InvalidClient(t *testing.T) {
|
|||
Scopes: []string{"openid", "profile"},
|
||||
Secret: "mysecret",
|
||||
}
|
||||
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
s, err := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
IssuerPrivateKey: exampleIssuerPrivateKey,
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{c1},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
client, err := s.GetInternalClient("another-client")
|
||||
assert.Nil(t, client)
|
||||
assert.EqualError(t, err, "not_found")
|
||||
}
|
||||
|
||||
func TestOpenIDConnectStore_IsValidClientID(t *testing.T) {
|
||||
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
s, err := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
|
||||
IssuerPrivateKey: exampleIssuerPrivateKey,
|
||||
Clients: []schema.OpenIDConnectClientConfiguration{
|
||||
{
|
||||
|
@ -124,6 +132,8 @@ func TestOpenIDConnectStore_IsValidClientID(t *testing.T) {
|
|||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
validClient := s.IsValidClientID("myclient")
|
||||
invalidClient := s.IsValidClientID("myinvalidclient")
|
||||
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"github.com/ory/fosite/storage"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/authelia/authelia/internal/authorization"
|
||||
)
|
||||
|
||||
// OpenIDConnectProvider for OpenID Connect.
|
||||
type OpenIDConnectProvider struct {
|
||||
Fosite fosite.OAuth2Provider
|
||||
Store *OpenIDConnectStore
|
||||
KeyManager *KeyManager
|
||||
}
|
||||
|
||||
// OpenIDConnectStore is Authelia's internal representation of the fosite.Storage interface.
|
||||
//
|
||||
// Currently it is mostly just implementing a decorator pattern other then GetInternalClient.
|
||||
// The long term plan is to have these methods interact with the Authelia storage and
|
||||
// session providers where applicable.
|
||||
type OpenIDConnectStore struct {
|
||||
clients map[string]*InternalClient
|
||||
memory *storage.MemoryStore
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Policy authorization.Level `json:"-"`
|
||||
|
||||
// These are the OpenIDConnect Client props.
|
||||
ResponseModes []fosite.ResponseModeType `json:"response_modes"`
|
||||
}
|
||||
|
||||
// KeyManager keeps track of all of the active/inactive rsa keys and provides them to services requiring them.
|
||||
// It additionally allows us to add keys for the purpose of key rotation in the future.
|
||||
type KeyManager struct {
|
||||
activeKeyID string
|
||||
keys map[string]*rsa.PrivateKey
|
||||
keySet *jose.JSONWebKeySet
|
||||
strategy *RS256JWTStrategy
|
||||
}
|
||||
|
||||
// AutheliaHasher implements the fosite.Hasher interface without an actual hashing algo.
|
||||
type AutheliaHasher struct{}
|
||||
|
||||
// ConsentGetResponseBody schema of the response body of the consent GET endpoint.
|
||||
type ConsentGetResponseBody struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientDescription string `json:"client_description"`
|
||||
Scopes []Scope `json:"scopes"`
|
||||
Audience []Audience `json:"audience"`
|
||||
}
|
||||
|
||||
// Scope represents the scope information.
|
||||
type Scope struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Audience represents the audience information.
|
||||
type Audience struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// WellKnownConfiguration is the OIDC well known config struct.
|
||||
//
|
||||
// See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
type WellKnownConfiguration struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
|
||||
Algorithms []string `json:"id_token_signing_alg_values_supported"`
|
||||
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ResponseModesSupported []string `json:"response_modes_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
|
||||
RequestURIParameterSupported bool `json:"request_uri_parameter_supported"`
|
||||
BackChannelLogoutSupported bool `json:"backchannel_logout_supported"`
|
||||
FrontChannelLogoutSupported bool `json:"frontchannel_logout_supported"`
|
||||
BackChannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported"`
|
||||
FrontChannelLogoutSessionSupported bool `json:"frontchannel_logout_session_supported"`
|
||||
}
|
||||
|
||||
// OpenIDSession holds OIDC Session information.
|
||||
type OpenIDSession struct {
|
||||
*openid.DefaultSession `json:"idToken"`
|
||||
|
||||
Extra map[string]interface{} `json:"extra"`
|
||||
ClientID string
|
||||
}
|
|
@ -2,12 +2,14 @@ package session
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/authorization"
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
)
|
||||
|
||||
|
@ -27,6 +29,7 @@ func TestShouldInitializerSession(t *testing.T) {
|
|||
|
||||
func TestShouldUpdateSession(t *testing.T) {
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
|
||||
configuration := schema.SessionConfiguration{}
|
||||
configuration.Domain = testDomain
|
||||
configuration.Name = testName
|
||||
|
@ -50,6 +53,77 @@ func TestShouldUpdateSession(t *testing.T) {
|
|||
}, session)
|
||||
}
|
||||
|
||||
func TestShouldSetSessionAuthenticationLevels(t *testing.T) {
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
configuration := schema.SessionConfiguration{}
|
||||
|
||||
timeOneFactor := time.Unix(1625048140, 0)
|
||||
timeTwoFactor := time.Unix(1625048150, 0)
|
||||
timeZeroFactor := time.Unix(0, 0)
|
||||
|
||||
configuration.Domain = testDomain
|
||||
configuration.Name = testName
|
||||
configuration.Expiration = testExpiration
|
||||
|
||||
provider := NewProvider(configuration, nil)
|
||||
session, _ := provider.GetSession(ctx)
|
||||
|
||||
session.SetOneFactor(timeOneFactor, &authentication.UserDetails{Username: testUsername}, false)
|
||||
|
||||
err := provider.SaveSession(ctx, session)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err = provider.GetSession(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
authAt, err := session.AuthenticatedTime(authorization.OneFactor)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, timeOneFactor, authAt)
|
||||
|
||||
authAt, err = session.AuthenticatedTime(authorization.TwoFactor)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, timeZeroFactor, authAt)
|
||||
|
||||
authAt, err = session.AuthenticatedTime(authorization.Denied)
|
||||
assert.EqualError(t, err, "invalid authorization level")
|
||||
assert.Equal(t, timeZeroFactor, authAt)
|
||||
|
||||
assert.Equal(t, UserSession{
|
||||
Username: testUsername,
|
||||
AuthenticationLevel: authentication.OneFactor,
|
||||
LastActivity: timeOneFactor.Unix(),
|
||||
FirstFactorAuthnTimestamp: timeOneFactor.Unix(),
|
||||
}, session)
|
||||
|
||||
session.SetTwoFactor(timeTwoFactor)
|
||||
|
||||
err = provider.SaveSession(ctx, session)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err = provider.GetSession(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, UserSession{
|
||||
Username: testUsername,
|
||||
AuthenticationLevel: authentication.TwoFactor,
|
||||
LastActivity: timeTwoFactor.Unix(),
|
||||
FirstFactorAuthnTimestamp: timeOneFactor.Unix(),
|
||||
SecondFactorAuthnTimestamp: timeTwoFactor.Unix(),
|
||||
}, session)
|
||||
|
||||
authAt, err = session.AuthenticatedTime(authorization.OneFactor)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, timeOneFactor, authAt)
|
||||
|
||||
authAt, err = session.AuthenticatedTime(authorization.TwoFactor)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, timeTwoFactor, authAt)
|
||||
|
||||
authAt, err = session.AuthenticatedTime(authorization.Denied)
|
||||
assert.EqualError(t, err, "invalid authorization level")
|
||||
assert.Equal(t, timeZeroFactor, authAt)
|
||||
}
|
||||
|
||||
func TestShouldDestroySessionAndWipeSessionData(t *testing.T) {
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
configuration := schema.SessionConfiguration{}
|
||||
|
|
|
@ -37,6 +37,9 @@ type UserSession struct {
|
|||
AuthenticationLevel authentication.Level
|
||||
LastActivity int64
|
||||
|
||||
FirstFactorAuthnTimestamp int64
|
||||
SecondFactorAuthnTimestamp int64
|
||||
|
||||
// The challenge generated in first step of U2F registration (after identity verification) or authentication.
|
||||
// This is used reused in the second phase to check that the challenge has been completed.
|
||||
U2FChallenge *u2f.Challenge
|
||||
|
@ -70,4 +73,5 @@ type OIDCWorkflowSession struct {
|
|||
TargetURI string
|
||||
AuthURI string
|
||||
RequiredAuthorizationLevel authorization.Level
|
||||
CreatedTimestamp int64
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/authorization"
|
||||
)
|
||||
|
||||
// NewDefaultUserSession create a default user session.
|
||||
|
@ -12,3 +16,36 @@ func NewDefaultUserSession() UserSession {
|
|||
LastActivity: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// SetOneFactor sets the expected property values for one factor authentication.
|
||||
func (s *UserSession) SetOneFactor(now time.Time, details *authentication.UserDetails, keepMeLoggedIn bool) {
|
||||
s.FirstFactorAuthnTimestamp = now.Unix()
|
||||
s.LastActivity = now.Unix()
|
||||
s.AuthenticationLevel = authentication.OneFactor
|
||||
|
||||
s.KeepMeLoggedIn = keepMeLoggedIn
|
||||
|
||||
s.Username = details.Username
|
||||
s.DisplayName = details.DisplayName
|
||||
s.Groups = details.Groups
|
||||
s.Emails = details.Emails
|
||||
}
|
||||
|
||||
// SetTwoFactor sets the expected property values for two factor authentication.
|
||||
func (s *UserSession) SetTwoFactor(now time.Time) {
|
||||
s.SecondFactorAuthnTimestamp = now.Unix()
|
||||
s.LastActivity = now.Unix()
|
||||
s.AuthenticationLevel = authentication.TwoFactor
|
||||
}
|
||||
|
||||
// AuthenticatedTime returns the unix timestamp this session authenticated successfully at the given level.
|
||||
func (s UserSession) AuthenticatedTime(level authorization.Level) (authenticatedTime time.Time, err error) {
|
||||
switch level {
|
||||
case authorization.OneFactor:
|
||||
return time.Unix(s.FirstFactorAuthnTimestamp, 0), nil
|
||||
case authorization.TwoFactor:
|
||||
return time.Unix(s.SecondFactorAuthnTimestamp, 0), nil
|
||||
default:
|
||||
return time.Unix(0, 0), errors.New("invalid authorization level")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,5 +19,9 @@ func (s *OIDCSuite) TestOIDCScenario() {
|
|||
}
|
||||
|
||||
func TestOIDCSuite(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping suite test in short mode")
|
||||
}
|
||||
|
||||
suite.Run(t, NewOIDCSuite())
|
||||
}
|
||||
|
|
|
@ -19,5 +19,9 @@ func (s *OIDCTraefikSuite) TestOIDCScenario() {
|
|||
}
|
||||
|
||||
func TestOIDCTraefikSuite(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping suite test in short mode")
|
||||
}
|
||||
|
||||
suite.Run(t, NewOIDCTraefikSuite())
|
||||
}
|
||||
|
|
|
@ -1,12 +1,29 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// IsStringAbsURL checks a string can be parsed as a URL and that is IsAbs and if it can't it returns an error
|
||||
// describing why.
|
||||
func IsStringAbsURL(input string) (err error) {
|
||||
parsedURL, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse '%s' as a URL", input)
|
||||
}
|
||||
|
||||
if !parsedURL.IsAbs() {
|
||||
return fmt.Errorf("the url '%s' is not absolute because it doesn't start with a scheme like 'http://' or 'https://'", input)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsStringAlphaNumeric returns false if any rune in the string is not alpha-numeric.
|
||||
func IsStringAlphaNumeric(input string) bool {
|
||||
for _, r := range input {
|
||||
|
|
Loading…
Reference in New Issue