From ef549f851d59ea5911b8ae76d4641c2c9eded6dc Mon Sep 17 00:00:00 2001 From: James Elliott Date: Sun, 4 Jul 2021 09:44:30 +1000 Subject: [PATCH] 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. --- cmd/authelia/main.go | 2 +- config.template.yml | 27 ++- docs/configuration/identity-providers/oidc.md | 223 ++++++++++++++++-- internal/configuration/config.template.yml | 25 +- .../schema/identity_providers.go | 31 ++- .../configuration/validator/configuration.go | 6 +- .../validator/configuration_test.go | 4 +- internal/configuration/validator/const.go | 43 +++- .../validator/identity_providers.go | 105 +++++++-- .../validator/identity_providers_test.go | 171 ++++++++++++-- internal/handlers/const.go | 10 - internal/handlers/handler_firstfactor.go | 16 +- internal/handlers/handler_oidc_authorize.go | 72 +++++- internal/handlers/handler_oidc_consent.go | 8 +- internal/handlers/handler_oidc_introspect.go | 9 +- internal/handlers/handler_oidc_jwks.go | 2 +- internal/handlers/handler_oidc_token.go | 8 +- internal/handlers/handler_oidc_wellknown.go | 105 +++++---- internal/handlers/handler_sign_duo.go | 7 +- internal/handlers/handler_sign_totp.go | 7 +- internal/handlers/handler_sign_u2f_step2.go | 7 +- internal/handlers/oidc.go | 95 +------- .../{register_oidc.go => oidc_register.go} | 2 +- internal/handlers/response.go | 4 +- internal/handlers/types_oidc.go | 51 ---- internal/oidc/client.go | 58 ++++- internal/oidc/client_test.go | 185 +++++++++++++++ internal/oidc/const.go | 10 + internal/oidc/hasher.go | 8 +- internal/oidc/helpers.go | 25 ++ internal/oidc/helpers_test.go | 49 ++++ internal/oidc/keys.go | 197 ++++++++++++++++ internal/oidc/keys_test.go | 53 +++++ internal/oidc/provider.go | 65 ++--- internal/oidc/provider_test.go | 40 +++- internal/oidc/store.go | 57 ++--- internal/oidc/store_test.go | 20 +- internal/oidc/types.go | 112 +++++++++ internal/session/provider_test.go | 74 ++++++ internal/session/types.go | 4 + internal/session/user_session.go | 37 +++ internal/suites/suite_oidc_test.go | 4 + internal/suites/suite_oidc_traefik_test.go | 4 + internal/utils/strings.go | 17 ++ 44 files changed, 1614 insertions(+), 445 deletions(-) rename internal/handlers/{register_oidc.go => oidc_register.go} (92%) create mode 100644 internal/oidc/const.go create mode 100644 internal/oidc/helpers.go create mode 100644 internal/oidc/helpers_test.go create mode 100644 internal/oidc/keys.go create mode 100644 internal/oidc/keys_test.go create mode 100644 internal/oidc/types.go diff --git a/cmd/authelia/main.go b/cmd/authelia/main.go index 7ca809108..791457410 100644 --- a/cmd/authelia/main.go +++ b/cmd/authelia/main.go @@ -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) } diff --git a/config.template.yml b/config.template.yml index 74ae25dce..06dd12999 100644 --- a/config.template.yml +++ b/config.template.yml @@ -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: # - @@ -603,7 +616,7 @@ notifier: ## Redirect URI's specifies a list of valid case-sensitive callbacks for this client. # redirect_uris: - # - https://oidc.example.com:8080/oauth2/callback + # - https://oidc.example.com:8080/oauth2/callback ## Scopes defines the valid scopes this client can request # scopes: @@ -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 ... diff --git a/docs/configuration/identity-providers/oidc.md b/docs/configuration/identity-providers/oidc.md index b4431a959..2236e1873 100644 --- a/docs/configuration/identity-providers/oidc.md +++ b/docs/configuration/identity-providers/oidc.md @@ -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 +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
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 +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
-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 +
+type: duration +{: .label .label-config .label-purple } +default: 1h +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +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 +
+type: duration +{: .label .label-config .label-purple } +default: 1m +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +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 +
+type: duration +{: .label .label-config .label-purple } +default: 1h +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +The maximum lifetime of an ID token. For more information read these docs about [token lifespan]. + +### refresh_token_lifespan +
+type: string +{: .label .label-config .label-purple } +default: 30d +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +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 +
+type: boolean +{: .label .label-config .label-purple } +default: false +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +Allows additional debug messages to be sent to the clients. + +### minimum_parameter_entropy +
+type: integer +{: .label .label-config .label-purple } +default: 8 +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +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 +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
The Client ID for this client. Must be configured in the application consuming this client. #### description +
+type: string +{: .label .label-config .label-purple } +default: *same as id* +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
A friendly description for this client shown in the UI. This defaults to the same as the ID. #### secret +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
The shared secret between Authelia and the application consuming this client. Currently this is stored in plain text. #### authorization_policy +
+type: string +{: .label .label-config .label-purple } +default: two_factor +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
The authorization policy for this client. Either `one_factor` or `two_factor`. #### redirect_uris +
+type: list(string) +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
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 +
+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 } +
A list of scopes to allow this client to consume. See [scope definitions](#scope-definitions) for more information. #### grant_types +
+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 } +
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 +
+type: list(string) +{: .label .label-config .label-purple } +default: code +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
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 +
+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 } +
+ +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 @@ -182,19 +361,19 @@ know what you're doing. This is the default scope for openid. This field is forced on every client by the configuration validation that Authelia does. -|JWT Field|JWT Type |Authelia Attribute|Description | -|:-------:|:-----------:|:----------------:|:--------------------------------------:| -|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 | -|jti |string(uuid) |_N/A_ |JWT Identifier | +|JWT Field|JWT Type |Authelia Attribute|Description | +|:-------:|:-----------:|:----------------:|:-------------------------------------------:| +|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 | +|aud |array[string]|_N/A_ |Audience | +|exp |number |_N/A_ |Expires | +|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 | -|email_verified|bool |_N/A_ |If the email is verified, assumed true for the time being| +|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 @@ -222,4 +402,5 @@ This scope includes the profile information the authentication backend reports a |name |string | display_name |The users display name| -[OpenID Connect]: https://openid.net/connect/ \ No newline at end of file +[OpenID Connect]: https://openid.net/connect/ +[token lifespan]: https://docs.apigee.com/api-platform/antipatterns/oauth-long-expiration \ No newline at end of file diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 4aaf09f64..06dd12999 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -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: # - @@ -603,7 +616,7 @@ notifier: ## Redirect URI's specifies a list of valid case-sensitive callbacks for this client. # redirect_uris: - # - https://oidc.example.com:8080/oauth2/callback + # - https://oidc.example.com:8080/oauth2/callback ## Scopes defines the valid scopes this client can request # scopes: @@ -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 ... diff --git a/internal/configuration/schema/identity_providers.go b/internal/configuration/schema/identity_providers.go index dd2d75190..3b43cc613 100644 --- a/internal/configuration/schema/identity_providers.go +++ b/internal/configuration/schema/identity_providers.go @@ -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"}, } diff --git a/internal/configuration/validator/configuration.go b/internal/configuration/validator/configuration.go index 98e1194ee..d8ce23c6c 100644 --- a/internal/configuration/validator/configuration.go +++ b/internal/configuration/validator/configuration.go @@ -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)) } } diff --git a/internal/configuration/validator/configuration_test.go b/internal/configuration/validator/configuration_test.go index 5bee64b04..5e4f4db1b 100644 --- a/internal/configuration/validator/configuration_test.go +++ b/internal/configuration/validator/configuration_test.go @@ -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) { diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index ba1cabe0e..b21d02e2b 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -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{ diff --git a/internal/configuration/validator/identity_providers.go b/internal/configuration/validator/identity_providers.go index 792621bbe..070510747 100644 --- a/internal/configuration/validator/identity_providers.go +++ b/internal/configuration/validator/identity_providers.go @@ -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)) } } } diff --git a/internal/configuration/validator/identity_providers_test.go b/internal/configuration/validator/identity_providers_test.go index 00e502722..47339c5b1 100644 --- a/internal/configuration/validator/identity_providers_test.go +++ b/internal/configuration/validator/identity_providers_test.go @@ -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) } diff --git a/internal/handlers/const.go b/internal/handlers/const.go index 2d53febf0..c24f066cb 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -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{} diff --git a/internal/handlers/handler_firstfactor.go b/internal/handlers/handler_firstfactor.go index 39b02cc43..4e2f08032 100644 --- a/internal/handlers/handler_firstfactor.go +++ b/internal/handlers/handler_firstfactor.go @@ -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) } diff --git a/internal/handlers/handler_oidc_authorize.go b/internal/handlers/handler_oidc_authorize.go index 3220949b6..9a9201300 100644 --- a/internal/handlers/handler_oidc_authorize.go +++ b/internal/handlers/handler_oidc_authorize.go @@ -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) diff --git a/internal/handlers/handler_oidc_consent.go b/internal/handlers/handler_oidc_consent.go index b96d68409..5beca47b9 100644 --- a/internal/handlers/handler_oidc_consent.go +++ b/internal/handlers/handler_oidc_consent.go @@ -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") } } diff --git a/internal/handlers/handler_oidc_introspect.go b/internal/handlers/handler_oidc_introspect.go index 6873c8d4a..0a46cdb11 100644 --- a/internal/handlers/handler_oidc_introspect.go +++ b/internal/handlers/handler_oidc_introspect.go @@ -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) diff --git a/internal/handlers/handler_oidc_jwks.go b/internal/handlers/handler_oidc_jwks.go index 6ca68f950..0ac128bda 100644 --- a/internal/handlers/handler_oidc_jwks.go +++ b/internal/handlers/handler_oidc_jwks.go @@ -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") } } diff --git a/internal/handlers/handler_oidc_token.go b/internal/handlers/handler_oidc_token.go index ce7f962c3..03f7925bf 100644 --- a/internal/handlers/handler_oidc_token.go +++ b/internal/handlers/handler_oidc_token.go @@ -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 { diff --git a/internal/handlers/handler_oidc_wellknown.go b/internal/handlers/handler_oidc_wellknown.go index bd7b9231e..c74209980 100644 --- a/internal/handlers/handler_oidc_wellknown.go +++ b/internal/handlers/handler_oidc_wellknown.go @@ -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,53 +20,68 @@ 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{ - "openid", - "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{ - "aud", - "exp", - "iat", - "iss", - "jti", - "rat", - "sub", - "auth_time", - "nonce", - "email", - "email_verified", - "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", + 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", + }, + ClaimsSupported: []string{ + "aud", + "exp", + "iat", + "iss", + "jti", + "rat", + "sub", + "auth_time", + "nonce", + "email", + "email_verified", + "alt_emails", + "groups", + "name", + }, + + 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) diff --git a/internal/handlers/handler_sign_duo.go b/internal/handlers/handler_sign_duo.go index bf4666e42..6be431a92 100644 --- a/internal/handlers/handler_sign_duo.go +++ b/internal/handlers/handler_sign_duo.go @@ -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) } diff --git a/internal/handlers/handler_sign_totp.go b/internal/handlers/handler_sign_totp.go index bc604e136..92b330a97 100644 --- a/internal/handlers/handler_sign_totp.go +++ b/internal/handlers/handler_sign_totp.go @@ -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) } diff --git a/internal/handlers/handler_sign_u2f_step2.go b/internal/handlers/handler_sign_u2f_step2.go index e7bd75f19..eba199e7f 100644 --- a/internal/handlers/handler_sign_u2f_step2.go +++ b/internal/handlers/handler_sign_u2f_step2.go @@ -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) } diff --git a/internal/handlers/oidc.go b/internal/handlers/oidc.go index 9501512d7..18feac82c 100644 --- a/internal/handlers/oidc.go +++ b/internal/handlers/oidc.go @@ -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{}{}, + } } diff --git a/internal/handlers/register_oidc.go b/internal/handlers/oidc_register.go similarity index 92% rename from internal/handlers/register_oidc.go rename to internal/handlers/oidc_register.go index 75d34faba..dd7489313 100644 --- a/internal/handlers/register_oidc.go +++ b/internal/handlers/oidc_register.go @@ -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)) diff --git a/internal/handlers/response.go b/internal/handlers/response.go index 702208792..5463f7154 100644 --- a/internal/handlers/response.go +++ b/internal/handlers/response.go @@ -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) { diff --git a/internal/handlers/types_oidc.go b/internal/handlers/types_oidc.go index 6ddedd506..0464bde68 100644 --- a/internal/handlers/types_oidc.go +++ b/internal/handlers/types_oidc.go @@ -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"` -} diff --git a/internal/oidc/client.go b/internal/oidc/client.go index d49c20d24..5c1e8ae15 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -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 +} diff --git a/internal/oidc/client_test.go b/internal/oidc/client_test.go index 2ecd75ae2..fbc0b4356 100644 --- a/internal/oidc/client_test.go +++ b/internal/oidc/client_test.go @@ -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()) +} diff --git a/internal/oidc/const.go b/internal/oidc/const.go new file mode 100644 index 000000000..e3ccde2b3 --- /dev/null +++ b/internal/oidc/const.go @@ -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{} diff --git a/internal/oidc/hasher.go b/internal/oidc/hasher.go index 29267080e..58f84a938 100644 --- a/internal/oidc/hasher.go +++ b/internal/oidc/hasher.go @@ -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 } diff --git a/internal/oidc/helpers.go b/internal/oidc/helpers.go new file mode 100644 index 000000000..daa241eda --- /dev/null +++ b/internal/oidc/helpers.go @@ -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 +} diff --git a/internal/oidc/helpers_test.go b/internal/oidc/helpers_test.go new file mode 100644 index 000000000..c4d790423 --- /dev/null +++ b/internal/oidc/helpers_test.go @@ -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) +} diff --git a/internal/oidc/keys.go b/internal/oidc/keys.go new file mode 100644 index 000000000..07fb78ebe --- /dev/null +++ b/internal/oidc/keys.go @@ -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 +} diff --git a/internal/oidc/keys_test.go b/internal/oidc/keys_test.go new file mode 100644 index 000000000..8ddfd71f8 --- /dev/null +++ b/internal/oidc/keys_test.go @@ -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()) +} diff --git a/internal/oidc/provider.go b/internal/oidc/provider.go index cf9ab8a7b..fba506b35 100644 --- a/internal/oidc/provider.go +++ b/internal/oidc/provider.go @@ -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 -} diff --git a/internal/oidc/provider_test.go b/internal/oidc/provider_test.go index 9eacfad29..32dcca33b 100644 --- a/internal/oidc/provider_test.go +++ b/internal/oidc/provider_test.go @@ -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) } diff --git a/internal/oidc/store.go b/internal/oidc/store.go index 0707ec784..10436950c 100644 --- a/internal/oidc/store.go +++ b/internal/oidc/store.go @@ -14,51 +14,30 @@ import ( ) // NewOpenIDConnectStore returns a new OpenIDConnectStore using the provided schema.OpenIDConnectConfiguration. -func NewOpenIDConnectStore(configuration *schema.OpenIDConnectConfiguration) (store *OpenIDConnectStore) { - store = &OpenIDConnectStore{} +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{}, + RefreshTokens: map[string]storage.StoreRefreshToken{}, + PKCES: map[string]fosite.Requester{}, + AccessTokenRequestIDs: map[string]string{}, + RefreshTokenRequestIDs: map[string]string{}, + }, + } 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) + 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) - 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.clients[client.ID] = NewClient(client) } - store.memory = &storage.MemoryStore{ - IDSessions: make(map[string]fosite.Requester), - Users: map[string]storage.MemoryUserRelation{}, - AuthorizeCodes: map[string]storage.StoreAuthorizeCode{}, - AccessTokens: map[string]fosite.Requester{}, - RefreshTokens: map[string]storage.StoreRefreshToken{}, - PKCES: map[string]fosite.Requester{}, - AccessTokenRequestIDs: map[string]string{}, - RefreshTokenRequestIDs: map[string]string{}, - } - - return store -} - -// 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 + return store, nil } // GetClientPolicy retrieves the policy from the client with the matching provided id. diff --git a/internal/oidc/store_test.go b/internal/oidc/store_test.go index 62bab4892..c29de08c2 100644 --- a/internal/oidc/store_test.go +++ b/internal/oidc/store_test.go @@ -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") diff --git a/internal/oidc/types.go b/internal/oidc/types.go new file mode 100644 index 000000000..3491d0d27 --- /dev/null +++ b/internal/oidc/types.go @@ -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 +} diff --git a/internal/session/provider_test.go b/internal/session/provider_test.go index 357b7fb5f..572a86b95 100644 --- a/internal/session/provider_test.go +++ b/internal/session/provider_test.go @@ -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{} diff --git a/internal/session/types.go b/internal/session/types.go index 3ebe30da6..812f97b7e 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -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 } diff --git a/internal/session/user_session.go b/internal/session/user_session.go index a148285bb..71781aece 100644 --- a/internal/session/user_session.go +++ b/internal/session/user_session.go @@ -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") + } +} diff --git a/internal/suites/suite_oidc_test.go b/internal/suites/suite_oidc_test.go index 46f93af99..4c6326e0f 100644 --- a/internal/suites/suite_oidc_test.go +++ b/internal/suites/suite_oidc_test.go @@ -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()) } diff --git a/internal/suites/suite_oidc_traefik_test.go b/internal/suites/suite_oidc_traefik_test.go index 9925ae9f0..38b8292ab 100644 --- a/internal/suites/suite_oidc_traefik_test.go +++ b/internal/suites/suite_oidc_traefik_test.go @@ -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()) } diff --git a/internal/utils/strings.go b/internal/utils/strings.go index 6f72e2905..781b0e5f3 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -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 {