feat(oidc): add additional config options, accurate token times, and refactoring (#1991)

* This gives admins more control over their OIDC installation exposing options that had defaults before. Things like lifespans for authorize codes, access tokens, id tokens, refresh tokens, a option to enable the debug client messages, minimum parameter entropy. It also allows admins to configure the response modes.
* Additionally this records specific values about a users session indicating when they performed a specific authz factor so this is represented in the token accurately. 
* Lastly we also implemented a OIDC key manager which calculates the kid for jwk's using the SHA1 digest instead of being static, or more specifically the first 7 chars. As per https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key#section-8.1.1 the kid should not exceed 8 chars. While it's allowed to exceed 8 chars, it must only be done so with a compelling reason, which we do not have.
pull/2141/head^2
James Elliott 2021-07-04 09:44:30 +10:00 committed by GitHub
parent 2dbd7ed219
commit ef549f851d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1614 additions and 445 deletions

View File

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

View File

@ -17,7 +17,7 @@ port: 9091
## They should be in base64 format, and have one of the following extensions: *.cer, *.crt, *.pem.
# certificates_directory: /config/certificates
## The theme to display: light, dark, grey.
## The theme to display: light, dark, grey, auto.
theme: light
##
@ -65,7 +65,7 @@ jwt_secret: a_very_important_secret
## in such a case.
##
## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication.
default_redirection_url: https://home.example.com:8080/
default_redirection_url: https://home.example.com/
##
## TOTP Configuration
@ -586,6 +586,19 @@ notifier:
# --- KEY START
# --- KEY END
## The lifespans configure the expiration for these token types.
# access_token_lifespan: 1h
# authorize_code_lifespan: 1m
# id_token_lifespan: 1h
# refresh_token_lifespan: 90m
## Enables additional debug messages.
# enable_client_debug_messages: false
## SECURITY NOTICE: It's not recommended to change this option, and highly discouraged to have it below 8 for
## security reasons.
# minimum_parameter_entropy: 8
## Clients is a list of known clients and their configuration.
# clients:
# -
@ -616,10 +629,16 @@ notifier:
## It's not recommended to define this unless you know what you're doing.
# grant_types:
# - refresh_token
# - "authorization_code
# - authorization_code
## Response Types configures which responses this client can be sent.
## It's not recommended to define this unless you know what you're doing.
# response_types:
# - code
## Response Modes configures which response modes this client supports.
# response_modes:
# - form_post
# - query
# - fragment
...

View File

@ -102,6 +102,11 @@ identity_providers:
issuer_private_key: |
--- KEY START
--- KEY END
access_token_lifespan: 1h
authorize_code_lifespan: 1m
id_token_lifespan: 1h
refresh_token_lifespan: 720h
enable_client_debug_messages: false
clients:
- id: myapp
description: My Application
@ -119,11 +124,21 @@ identity_providers:
- authorization_code
response_types:
- code
response_modes:
- form_post
- query
- fragment
```
## Options
### hmac_secret
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
The HMAC secret used to sign the [OpenID Connect] JWT's. The provided string is hashed to a SHA256 byte string for
the purpose of meeting the required format.
@ -131,49 +146,213 @@ the purpose of meeting the required format.
Can also be defined using a [secret](../secrets.md) which is the recommended for containerized deployments.
### issuer_private_key
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
The private key in DER base64 encoded PEM format used to encrypt the [OpenID Connect] JWT's.
The private key in DER base64 encoded PEM format used to encrypt the [OpenID Connect] JWT's. This can easily be
generated using the Authelia binary using the following syntax:
```console
authelia rsa generate --dir /config
```
Can also be defined using a [secret](../secrets.md) which is the recommended for containerized deployments.
### access_token_lifespan
<div markdown="1">
type: duration
{: .label .label-config .label-purple }
default: 1h
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
The maximum lifetime of an access token. It's generally recommended keeping this short similar to the default.
For more information read these docs about [token lifespan].
### authorize_code_lifespan
<div markdown="1">
type: duration
{: .label .label-config .label-purple }
default: 1m
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
The maximum lifetime of an authorize code. This can be rather short, as the authorize code should only be needed to
obtain the other token types. For more information read these docs about [token lifespan].
### id_token_lifespan
<div markdown="1">
type: duration
{: .label .label-config .label-purple }
default: 1h
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
The maximum lifetime of an ID token. For more information read these docs about [token lifespan].
### refresh_token_lifespan
<div markdown="1">
type: string
{: .label .label-config .label-purple }
default: 30d
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
The maximum lifetime of a refresh token. This should typically be slightly more the other token lifespans. This is
because the refresh token can be used to obtain new refresh tokens as well as access tokens or id tokens with an
up-to-date expiration. For more information read these docs about [token lifespan].
### enable_client_debug_messages
<div markdown="1">
type: boolean
{: .label .label-config .label-purple }
default: false
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
Allows additional debug messages to be sent to the clients.
### minimum_parameter_entropy
<div markdown="1">
type: integer
{: .label .label-config .label-purple }
default: 8
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
This controls the minimum length of the `nonce` and `state` parameters.
***Security Notice:*** Changing this value is generally discouraged, reducing it from the default can theoretically make
certain scenarios less secure. It highly encouraged that if your OpenID Connect RP does not send these parameters or
sends parameters with a lower length than the default that they implement a change rather than changing this value.
### clients
A list of clients to configure. The options for each client are described below.
#### id
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
The Client ID for this client. Must be configured in the application consuming this client.
#### description
<div markdown="1">
type: string
{: .label .label-config .label-purple }
default: *same as id*
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
A friendly description for this client shown in the UI. This defaults to the same as the ID.
#### secret
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
The shared secret between Authelia and the application consuming this client. Currently this is stored in plain text.
#### authorization_policy
<div markdown="1">
type: string
{: .label .label-config .label-purple }
default: two_factor
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
The authorization policy for this client. Either `one_factor` or `two_factor`.
#### redirect_uris
<div markdown="1">
type: list(string)
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
A list of valid callback URL's this client will redirect to. All other callbacks will be considered unsafe. The URL's
are case-sensitive.
#### scopes
<div markdown="1">
type: list(string)
{: .label .label-config .label-purple }
default: openid, groups, profile, email
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
A list of scopes to allow this client to consume. See [scope definitions](#scope-definitions) for more information.
#### grant_types
<div markdown="1">
type: list(string)
{: .label .label-config .label-purple }
default: refresh_token, authorization_code
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
A list of grant types this client can return. It is recommended that this isn't configured at this time unless you know
what you're doing.
what you're doing. Valid options are: `implicit`, `refresh_token`, `authorization_code`, `password`,
`client_credentials`.
#### response_types
<div markdown="1">
type: list(string)
{: .label .label-config .label-purple }
default: code
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
A list of response types this client can return. It is recommended that this isn't configured at this time unless you
know what you're doing.
know what you're doing. Valid options are: `code`, `code id_token`, `id_token`, `token id_token`, `token`,
`token id_token code`.
#### response_modes
<div markdown="1">
type: list(string)
{: .label .label-config .label-purple }
default: form_post, query, fragment
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
A list of response modes this client can return. It is recommended that this isn't configured at this time unless you
know what you're doing. Potential values are `form_post`, `query`, and `fragment`.
## Scope Definitions
@ -183,17 +362,17 @@ This is the default scope for openid. This field is forced on every client by th
validation that Authelia does.
|JWT Field|JWT Type |Authelia Attribute|Description |
|:-------:|:-----------:|:----------------:|:--------------------------------------:|
|:-------:|:-----------:|:----------------:|:-------------------------------------------:|
|sub |string |Username |The username the user used to login with |
|scope |string |scopes |Granted scopes (space delimited) |
|scp |array[string]|scopes |Granted scopes |
|iss |string |hostname |The issuer name, determined by URL |
|at_hash |string |_N/A_ |Access Token Hash |
|auth_time|number |_N/A_ |Authorize Time |
|aud |array[string]|_N/A_ |Audience |
|exp |number |_N/A_ |Expires |
|iat |number |_N/A_ |Issued At |
|rat |number |_N/A_ |Requested At |
|auth_time|number |_N/A_ |The time the user authenticated with Authelia|
|rat |number |_N/A_ |The time when the token was requested |
|iat |number |_N/A_ |The time when the token was issued |
|jti |string(uuid) |_N/A_ |JWT Identifier |
### groups
@ -209,9 +388,10 @@ 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 |string |email[0] |The first email address in the list of emails |
|email_verified|bool |_N/A_ |If the email is verified, assumed true for the time being|
|alt_emails |array[string]|email[1:] |All email addresses that are not in the email JWT field |
### profile
@ -223,3 +403,4 @@ This scope includes the profile information the authentication backend reports a
[OpenID Connect]: https://openid.net/connect/
[token lifespan]: https://docs.apigee.com/api-platform/antipatterns/oauth-long-expiration

View File

@ -65,7 +65,7 @@ jwt_secret: a_very_important_secret
## in such a case.
##
## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication.
default_redirection_url: https://home.example.com:8080/
default_redirection_url: https://home.example.com/
##
## TOTP Configuration
@ -586,6 +586,19 @@ notifier:
# --- KEY START
# --- KEY END
## The lifespans configure the expiration for these token types.
# access_token_lifespan: 1h
# authorize_code_lifespan: 1m
# id_token_lifespan: 1h
# refresh_token_lifespan: 90m
## Enables additional debug messages.
# enable_client_debug_messages: false
## SECURITY NOTICE: It's not recommended to change this option, and highly discouraged to have it below 8 for
## security reasons.
# minimum_parameter_entropy: 8
## Clients is a list of known clients and their configuration.
# clients:
# -
@ -616,10 +629,16 @@ notifier:
## It's not recommended to define this unless you know what you're doing.
# grant_types:
# - refresh_token
# - "authorization_code
# - authorization_code
## Response Types configures which responses this client can be sent.
## It's not recommended to define this unless you know what you're doing.
# response_types:
# - code
## Response Modes configures which response modes this client supports.
# response_modes:
# - form_post
# - query
# - fragment
...

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,11 +7,11 @@ import (
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/internal/middlewares"
"github.com/authelia/authelia/internal/oidc"
)
func oidcWellKnown(ctx *middlewares.AutheliaCtx) {
var configuration WellKnownConfigurationJSON
// TODO (james-d-elliott): append the server.path here for path based installs. Also check other instances in OIDC.
issuer, err := ctx.ForwardedProtoHost()
if err != nil {
ctx.Logger.Errorf("Error occurred in ForwardedProtoHost: %+v", err)
@ -20,22 +20,42 @@ func oidcWellKnown(ctx *middlewares.AutheliaCtx) {
return
}
configuration.Issuer = issuer
configuration.AuthURL = fmt.Sprintf("%s%s", issuer, oidcAuthorizePath)
configuration.TokenURL = fmt.Sprintf("%s%s", issuer, oidcTokenPath)
configuration.RevocationEndpoint = fmt.Sprintf("%s%s", issuer, oidcRevokePath)
configuration.JWKSURL = fmt.Sprintf("%s%s", issuer, oidcJWKsPath)
configuration.Algorithms = []string{"RS256"}
configuration.ScopesSupported = []string{
wellKnown := oidc.WellKnownConfiguration{
Issuer: issuer,
JWKSURI: fmt.Sprintf("%s%s", issuer, oidcJWKsPath),
AuthorizationEndpoint: fmt.Sprintf("%s%s", issuer, oidcAuthorizePath),
TokenEndpoint: fmt.Sprintf("%s%s", issuer, oidcTokenPath),
RevocationEndpoint: fmt.Sprintf("%s%s", issuer, oidcRevokePath),
Algorithms: []string{"RS256"},
SubjectTypesSupported: []string{
"public",
},
ResponseTypesSupported: []string{
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none",
},
ResponseModesSupported: []string{
"form_post",
"query",
"fragment",
},
ScopesSupported: []string{
"openid",
"offline_access",
"profile",
"groups",
"email",
// Determine if this is really mandatory knowing the RP can request for a refresh token through the authorize
// endpoint anyway.
"offline_access",
}
configuration.ClaimsSupported = []string{
},
ClaimsSupported: []string{
"aud",
"exp",
"iat",
@ -47,26 +67,21 @@ func oidcWellKnown(ctx *middlewares.AutheliaCtx) {
"nonce",
"email",
"email_verified",
"alt_emails",
"groups",
"name",
}
configuration.SubjectTypesSupported = []string{
"public",
}
configuration.ResponseTypesSupported = []string{
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none",
},
RequestURIParameterSupported: false,
BackChannelLogoutSupported: false,
FrontChannelLogoutSupported: false,
BackChannelLogoutSessionSupported: false,
FrontChannelLogoutSessionSupported: false,
}
ctx.SetContentType("application/json")
if err := json.NewEncoder(ctx).Encode(configuration); err != nil {
if err := json.NewEncoder(ctx).Encode(wellKnown); err != nil {
ctx.Logger.Errorf("Error occurred in json Encode: %+v", err)
// TODO: Determine if this is the appropriate error code here.
ctx.Response.SetStatusCode(fasthttp.StatusInternalServerError)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,31 +14,10 @@ import (
)
// NewOpenIDConnectStore returns a new OpenIDConnectStore using the provided schema.OpenIDConnectConfiguration.
func NewOpenIDConnectStore(configuration *schema.OpenIDConnectConfiguration) (store *OpenIDConnectStore) {
store = &OpenIDConnectStore{}
store.clients = make(map[string]*InternalClient)
for _, clientConf := range configuration.Clients {
policy := authorization.PolicyToLevel(clientConf.Policy)
logging.Logger().Debugf("Registering client %s with policy %s (%v)", clientConf.ID, clientConf.Policy, policy)
client := &InternalClient{
ID: clientConf.ID,
Description: clientConf.Description,
Policy: authorization.PolicyToLevel(clientConf.Policy),
Secret: []byte(clientConf.Secret),
RedirectURIs: clientConf.RedirectURIs,
GrantTypes: clientConf.GrantTypes,
ResponseTypes: clientConf.ResponseTypes,
Scopes: clientConf.Scopes,
}
store.clients[client.ID] = client
}
store.memory = &storage.MemoryStore{
IDSessions: make(map[string]fosite.Requester),
func NewOpenIDConnectStore(configuration *schema.OpenIDConnectConfiguration) (store *OpenIDConnectStore, err error) {
store = &OpenIDConnectStore{
memory: &storage.MemoryStore{
IDSessions: map[string]fosite.Requester{},
Users: map[string]storage.MemoryUserRelation{},
AuthorizeCodes: map[string]storage.StoreAuthorizeCode{},
AccessTokens: map[string]fosite.Requester{},
@ -46,19 +25,19 @@ func NewOpenIDConnectStore(configuration *schema.OpenIDConnectConfiguration) (st
PKCES: map[string]fosite.Requester{},
AccessTokenRequestIDs: map[string]string{},
RefreshTokenRequestIDs: map[string]string{},
},
}
return store
store.clients = make(map[string]*InternalClient)
for _, client := range configuration.Clients {
policy := authorization.PolicyToLevel(client.Policy)
logging.Logger().Debugf("Registering client %s with policy %s (%v)", client.ID, client.Policy, policy)
store.clients[client.ID] = NewClient(client)
}
// 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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