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 {