feature(oidc): add support for OpenID Connect

OpenID connect has become a standard when it comes to authentication and
in order to fix a security concern around forwarding authentication and authorization information
it has been decided to add support for it.

This feature is in beta version and only enabled when there is a configuration for it.
Before enabling it in production, please consider that it's in beta with potential bugs and that there
are several production critical features still missing such as all OIDC related data is stored in
configuration or memory. This means you are potentially going to experience issues with HA
deployments, or when restarting a single instance specifically related to OIDC.

We are still working on adding the remaining set of features before making it GA as soon as possible.

Related to #189

Co-authored-by: Clement Michaud <clement.michaud34@gmail.com>
pull/1971/head
James Elliott 2021-05-05 08:06:05 +10:00 committed by Clément Michaud
parent 48d8e1e541
commit ddea31193b
No known key found for this signature in database
GPG Key ID: 8589F016F9E4073D
108 changed files with 4621 additions and 232 deletions

View File

@ -50,7 +50,7 @@ Here is what Authelia's portal looks like
Here is the list of the main available features: Here is the list of the main available features:
* Several kind of second factor: * Several second factor methods:
* **[Security Key (U2F)](https://www.authelia.com/docs/features/2fa/security-key)** with [Yubikey]. * **[Security Key (U2F)](https://www.authelia.com/docs/features/2fa/security-key)** with [Yubikey].
* **[Time-based One-Time password](https://www.authelia.com/docs/features/2fa/one-time-password)** * **[Time-based One-Time password](https://www.authelia.com/docs/features/2fa/one-time-password)**
with [Google Authenticator]. with [Google Authenticator].
@ -61,6 +61,7 @@ Here is the list of the main available features:
* Access restriction after too many authentication attempts. * Access restriction after too many authentication attempts.
* Fine-grained access control per subdomain, user, resource and network. * Fine-grained access control per subdomain, user, resource and network.
* Support of basic authentication for endpoints protected by single factor. * Support of basic authentication for endpoints protected by single factor.
* Beta support for [OpenID Connect](https://www.authelia.com/docs/configuration/identity-providers/oidc.html).
* Highly available using a remote database and Redis as a highly available KV store. * Highly available using a remote database and Redis as a highly available KV store.
* Compatible with Kubernetes [ingress-nginx](https://github.com/kubernetes/ingress-nginx) controller out of the box. * Compatible with Kubernetes [ingress-nginx](https://github.com/kubernetes/ingress-nginx) controller out of the box.

View File

@ -59,6 +59,9 @@ var hostEntries = []HostEntry{
// Kubernetes dashboard. // Kubernetes dashboard.
{Domain: "kubernetes.example.com", IP: "192.168.240.110"}, {Domain: "kubernetes.example.com", IP: "192.168.240.110"},
// OIDC tester app
{Domain: "oidc.example.com", IP: "192.168.240.100"},
{Domain: "oidc-public.example.com", IP: "192.168.240.100"},
} }
func runCommand(cmd string, args ...string) { func runCommand(cmd string, args ...string) {

View File

@ -59,7 +59,7 @@ var Commands = []AutheliaCommandDefinition{
}, },
{ {
Name: "suites", Name: "suites",
Short: "Compute hash of a password for creating a file-based users database", Short: "Commands related to suites management",
SubCommands: CobraCommands{ SubCommands: CobraCommands{
SuitesTestCmd, SuitesTestCmd,
SuitesListCmd, SuitesListCmd,
@ -135,7 +135,7 @@ func main() {
cobraCommands = append(cobraCommands, command) cobraCommands = append(cobraCommands, command)
} }
cobraCommands = append(cobraCommands, commands.HashPasswordCmd) cobraCommands = append(cobraCommands, commands.HashPasswordCmd, commands.CertificatesCmd, commands.RSACmd)
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Set the log level for the command") rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Set the log level for the command")
rootCmd.AddCommand(cobraCommands...) rootCmd.AddCommand(cobraCommands...)

View File

@ -83,7 +83,7 @@ func setupSuite(cmd *cobra.Command, args []string) {
suiteResourcePath := cwd + "/internal/suites/" + suiteName suiteResourcePath := cwd + "/internal/suites/" + suiteName
exist, err := utils.FileExists(suiteResourcePath) exist, err := utils.PathExists(suiteResourcePath)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)

View File

@ -14,6 +14,7 @@ import (
"github.com/authelia/authelia/internal/logging" "github.com/authelia/authelia/internal/logging"
"github.com/authelia/authelia/internal/middlewares" "github.com/authelia/authelia/internal/middlewares"
"github.com/authelia/authelia/internal/notification" "github.com/authelia/authelia/internal/notification"
"github.com/authelia/authelia/internal/oidc"
"github.com/authelia/authelia/internal/regulation" "github.com/authelia/authelia/internal/regulation"
"github.com/authelia/authelia/internal/server" "github.com/authelia/authelia/internal/server"
"github.com/authelia/authelia/internal/session" "github.com/authelia/authelia/internal/session"
@ -117,15 +118,22 @@ func startServer() {
authorizer := authorization.NewAuthorizer(config.AccessControl) authorizer := authorization.NewAuthorizer(config.AccessControl)
sessionProvider := session.NewProvider(config.Session, autheliaCertPool) sessionProvider := session.NewProvider(config.Session, autheliaCertPool)
regulator := regulation.NewRegulator(config.Regulation, storageProvider, clock) regulator := regulation.NewRegulator(config.Regulation, storageProvider, clock)
oidcProvider, err := oidc.NewOpenIDConnectProvider(config.IdentityProviders.OIDC)
if err != nil {
panic(err)
}
providers := middlewares.Providers{ providers := middlewares.Providers{
Authorizer: authorizer, Authorizer: authorizer,
UserProvider: userProvider, UserProvider: userProvider,
Regulator: regulator, Regulator: regulator,
OpenIDConnect: oidcProvider,
StorageProvider: storageProvider, StorageProvider: storageProvider,
Notifier: notifier, Notifier: notifier,
SessionProvider: sessionProvider, SessionProvider: sessionProvider,
} }
server.StartServer(*config, providers) server.StartServer(*config, providers)
} }
@ -149,7 +157,8 @@ func main() {
} }
rootCmd.AddCommand(versionCmd, commands.HashPasswordCmd, rootCmd.AddCommand(versionCmd, commands.HashPasswordCmd,
commands.ValidateConfigCmd, commands.CertificatesCmd) commands.ValidateConfigCmd, commands.CertificatesCmd,
commands.RSACmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
logger.Fatal(err) logger.Fatal(err)

View File

@ -553,4 +553,62 @@ notifier:
# sender: admin@example.com # sender: admin@example.com
# host: smtp.gmail.com # host: smtp.gmail.com
# port: 587 # port: 587
##
## Identity Providers
##
# identity_providers:
##
## OpenID Connect (Identity Provider)
##
## It's recommended you read the documentation before configuration of this section:
## https://www.authelia.com/docs/configuration/identity-providers/oidc.html
# oidc:
## The hmac_secret is used to sign OAuth2 tokens (authorization code, access tokens and refresh tokens).
## HMAC Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
# hmac_secret: this_is_a_secret_abc123abc123abc
## The issuer_private_key is used to sign the JWT forged by OpenID Connect.
## Issuer Private Key can also be set using a secret: https://docs.authelia.com/configuration/secrets.html
# issuer_private_key: |
# --- KEY START
# --- KEY END
## Clients is a list of known clients and their configuration.
# clients:
# -
## The ID is the OpenID Connect ClientID which is used to link an application to a configuration.
# id: myapp
## The description to show to users when they end up on the consent screen. Defaults to the ID above.
# description: My Application
## The client secret is a shared secret between Authelia and the consumer of this client.
# secret: this_is_a_secret
## The policy to require for this client; one_factor or two_factor.
# authorization_policy: two_factor
## Redirect URI's specifies a list of valid case-sensitive callbacks for this client.
# redirect_uris:
# - https://oidc.example.com:8080/oauth2/callback
## Scopes defines the valid scopes this client can request
# scopes:
# - openid
# - groups
# - email
# - profile
## Grant Types configures which grants this client can obtain.
## It's not recommended to define this unless you know what you're doing.
# grant_types:
# - refresh_token
# - "authorization_code
## 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
... ...

View File

@ -1,3 +1,10 @@
.label.label-config { .label.label-config {
text-transform: none; text-transform: none;
}
.tbl-header {
font-weight: bold;
text-align: center;
}
.tbl-beta-stage {
border-bottom-width: 3px !important;
} }

View File

@ -0,0 +1,12 @@
---
layout: default
title: Identity Providers
parent: Configuration
nav_order: 12
has_children: true
---
# Identity Providers
This section covers configuration of the identity server characteristics of Authelia. Currently the only identity server
supported is OpenID Connect.

View File

@ -0,0 +1,225 @@
---
layout: default
title: OpenID Connect
parent: Identity Providers
grand_parent: Configuration
nav_order: 2
---
# OpenID Connect
**Authelia** currently supports the [OpenID Connect] OP role as a [beta](#beta) feature. The OP role is the
[OpenID Connect] Provider role, not the Relaying Party or RP role. This means other applications that implement the
[OpenID Connect] RP role can use Authelia as an authentication and authorization backend similar to how you may use
social media or development platforms for login.
The Relaying Party role is the role which allows an application to use GitHub, Google, or other [OpenID Connect]
providers for authentication and authorization. We do not intend to support this functionality at this moment in time.
## Beta
We have decided to implement [OpenID Connect] as a beta feature, it's suggested you only utilize it for testing and
providing feedback, and should take caution in relying on it in production. [OpenID Connect] and it's related endpoints
are not enabled by default unless you specifically configure the [OpenID Connect] section.
The beta will be broken up into stages. Each stage will bring additional features. The following table is a *rough* plan
for which stage will have each feature, and may evolve over time:
<table>
<thead>
<tr>
<th class="tbl-header">Stage</th>
<th class="tbl-header">Feature Description</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="7" class="tbl-header tbl-beta-stage">beta1</td>
<td><a href="https://openid.net/specs/openid-connect-core-1_0.html#Consent" target="_blank" rel="noopener noreferrer">User Consent</a></td>
</tr>
<tr>
<td><a href="https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowSteps" target="_blank" rel="noopener noreferrer">Authorization Code Flow</a></td>
</tr>
<tr>
<td><a href="https://openid.net/specs/openid-connect-discovery-1_0.html" target="_blank" rel="noopener noreferrer">OpenID Connect Discovery</a></td>
</tr>
<tr>
<td>RS256 Signature Strategy</td>
</tr>
<tr>
<td>Per Client Scope/Grant Type/Response Type Restriction</td>
</tr>
<tr>
<td>Per Client Authorization Policy (1FA/2FA)</td>
</tr>
<tr>
<td class="tbl-beta-stage">Per Client List of Valid Redirection URI's</td>
</tr>
<tr>
<td rowspan="2" class="tbl-header tbl-beta-stage">beta2 <sup>1</sup></td>
<td>Token Storage</td>
</tr>
<tr>
<td class="tbl-beta-stage">Audit Storage</td>
</tr>
<tr>
<td rowspan="4" class="tbl-header tbl-beta-stage">beta3 <sup>1</sup></td>
<td><a href="https://openid.net/specs/openid-connect-backchannel-1_0.html" target="_blank" rel="noopener noreferrer">Back-Channel Logout</a></td>
</tr>
<tr>
<td>Deny Refresh on Session Expiration</td>
</tr>
<tr>
<td><a href="https://openid.net/specs/openid-connect-messages-1_0-20.html#rotate.sig.keys" target="_blank" rel="noopener noreferrer">Signing Key Rotation Policy</a></td>
</tr>
<tr>
<td class="tbl-beta-stage">Client Secrets Hashed in Configuration</td>
</tr>
<tr>
<td class="tbl-header tbl-beta-stage">GA <sup>1</sup></td>
<td class="tbl-beta-stage">General Availability after previous stages are vetted for bug fixes</td>
</tr>
<tr>
<td rowspan="2" class="tbl-header">misc</td>
<td>List of other features that may be implemented</td>
</tr>
<tr>
<td class="tbl-beta-stage"><a href="https://openid.net/specs/openid-connect-frontchannel-1_0.html" target="_blank" rel="noopener noreferrer">Front-Channel Logout</a> <sup>2</sup></td>
</tr>
</tbody>
</table>
*<sup>1</sup> this stage has not been implemented as of yet*
*<sup>2</sup> this individual feature has not been implemented as of yet*
## Configuration
```yaml
identity_providers:
oidc:
hmac_secret: this_is_a_secret_abc123abc123abc
issuer_private_key: |
--- KEY START
--- KEY END
clients:
- id: myapp
description: My Application
secret: this_is_a_secret
authorization_policy: two_factor
redirect_uris:
- https://oidc.example.com:8080/oauth2/callback
scopes:
- openid
- groups
- email
- profile
grant_types:
- refresh_token
- authorization_code
response_types:
- code
```
## Options
### hmac_secret
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.
Can also be defined using a [secret](../secrets.md) which is the recommended for containerized deployments.
### issuer_private_key
The private key in DER base64 encoded PEM format used to encrypt the [OpenID Connect] JWT's.
Can also be defined using a [secret](../secrets.md) which is the recommended for containerized deployments.
### clients
A list of clients to configure. The options for each client are described below.
#### id
The Client ID for this client. Must be configured in the application consuming this client.
#### description
A friendly description for this client shown in the UI. This defaults to the same as the ID.
#### secret
The shared secret between Authelia and the application consuming this client. Currently this is stored in plain text.
#### authorization_policy
The authorization policy for this client. Either `one_factor` or `two_factor`.
#### redirect_uris
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
A list of scopes to allow this client to consume. See [scope definitions](#scope-definitions) for more information.
#### grant_types
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.
#### response_types
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.
## Scope Definitions
### openid
This is the default scope for openid. This field is forced on every client by the configuration
validation that Authelia does.
|JWT Field|JWT Type |Authelia Attribute|Description |
|:-------:|:-----------:|:----------------:|:--------------------------------------:|
|sub |string |Username |The username the user used to login with|
|scope |string |scopes |Granted scopes (space delimited) |
|scp |array[string]|scopes |Granted scopes |
|iss |string |hostname |The issuer name, determined by URL |
|at_hash |string |_N/A_ |Access Token Hash |
|auth_time|number |_N/A_ |Authorize Time |
|aud |array[string]|_N/A_ |Audience |
|exp |number |_N/A_ |Expires |
|iat |number |_N/A_ |Issued At |
|rat |number |_N/A_ |Requested At |
|jti |string(uuid) |_N/A_ |JWT Identifier |
### groups
This scope includes the groups the authentication backend reports the user is a member of in the token.
|JWT Field|JWT Type |Authelia Attribute|Description |
|:-------:|:-----------:|:----------------:|:--------------------:|
|groups |array[string]|Groups |The users display name|
### email
This scope includes the email information the authentication backend reports about the user in the token.
|JWT Field |JWT Type|Authelia Attribute|Description |
|:------------:|:------:|:----------------:|:-------------------------------------------------------:|
|email |string |email[0] |The first email in the list of emails |
|email_verified|bool |_N/A_ |If the email is verified, assumed true for the time being|
### profile
This scope includes the profile information the authentication backend reports about the user in the token.
|JWT Field|JWT Type|Authelia Attribute|Description |
|:-------:|:------:|:----------------:|:--------------------:|
|name |string | display_name |The users display name|
[OpenID Connect]: https://openid.net/connect/

View File

@ -40,7 +40,7 @@ so that you can test it in minutes. Let's begin with the
## However, Authelia... ## However, Authelia...
* is not an OAuth or OpenID Connect provider yet (planned in the [roadmap](./roadmap.md)) * [OpenID Connect](./configuration/identity-providers/oidc.md) is still in preview.
* is not a SAML provider yet. * is not a SAML provider yet.
* does not support authentication against an OAuth or OpenID Connect provider yet. * does not support authentication against an OAuth or OpenID Connect provider yet.
* does not support authentication against a SAML provider yet. * does not support authentication against a SAML provider yet.

View File

@ -14,7 +14,9 @@ ideas and plans with you.
Below are the prioritised roadmap items: Below are the prioritised roadmap items:
1. [Authelia acts as an OpenID Connect Provider](https://github.com/authelia/authelia/issues/189). This is a high 1. **[In Preview](./configuration/identity-providers/oidc.md)** *this roadmap item is in preview status, more
information can be found in the docs*.
[Authelia acts as an OpenID Connect Provider](https://github.com/authelia/authelia/issues/189). This is a high
priority because currently the only way to pass authentication information back to the protected app is through the priority because currently the only way to pass authentication information back to the protected app is through the
use of HTTP headers as described use of HTTP headers as described
[here](https://www.authelia.com/docs/deployment/supported-proxies/#how-can-the-backend-be-aware-of-the-authenticated-users) [here](https://www.authelia.com/docs/deployment/supported-proxies/#how-can-the-backend-be-aware-of-the-authenticated-users)

5
go.mod
View File

@ -17,9 +17,9 @@ require (
github.com/go-sql-driver/mysql v1.6.0 github.com/go-sql-driver/mysql v1.6.0
github.com/golang/mock v1.5.0 github.com/golang/mock v1.5.0
github.com/jackc/pgx/v4 v4.11.0 github.com/jackc/pgx/v4 v4.11.0
github.com/mattn/go-sqlite3 v1.14.7 github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/ory/fosite v0.39.0
github.com/otiai10/copy v1.5.1 github.com/otiai10/copy v1.5.1
github.com/pelletier/go-toml v1.4.0 // indirect
github.com/pquerna/otp v1.3.0 github.com/pquerna/otp v1.3.0
github.com/simia-tech/crypt v0.5.0 github.com/simia-tech/crypt v0.5.0
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1
@ -30,5 +30,6 @@ require (
github.com/tstranex/u2f v1.0.0 github.com/tstranex/u2f v1.0.0
github.com/valyala/fasthttp v1.24.0 github.com/valyala/fasthttp v1.24.0
golang.org/x/text v0.3.6 golang.org/x/text v0.3.6
gopkg.in/square/go-jose.v2 v2.5.1
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )

687
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/configuration/schema" "github.com/authelia/authelia/internal/configuration/schema"
) )
@ -175,3 +176,17 @@ func domainToPrefixSuffix(domain string) (prefix, suffix string) {
return parts[0], strings.Join(parts[1:], ".") return parts[0], strings.Join(parts[1:], ".")
} }
// IsAuthLevelSufficient returns true if the current authenticationLevel is above the authorizationLevel.
func IsAuthLevelSufficient(authenticationLevel authentication.Level, authorizationLevel Level) bool {
switch authorizationLevel {
case Denied:
return false
case OneFactor:
return authenticationLevel >= authentication.OneFactor
case TwoFactor:
return authenticationLevel >= authentication.TwoFactor
}
return true
}

View File

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/configuration/schema" "github.com/authelia/authelia/internal/configuration/schema"
) )
@ -182,3 +183,15 @@ func TestShouldParseACLNetworks(t *testing.T) {
assert.Equal(t, fourthNetwork, networksCacheMap["fec0::1"]) assert.Equal(t, fourthNetwork, networksCacheMap["fec0::1"])
assert.Equal(t, fourthNetwork, networksCacheMap["fec0::1/128"]) assert.Equal(t, fourthNetwork, networksCacheMap["fec0::1/128"])
} }
func TestShouldReturnCorrectValidationLevel(t *testing.T) {
assert.True(t, IsAuthLevelSufficient(authentication.NotAuthenticated, Bypass))
assert.True(t, IsAuthLevelSufficient(authentication.OneFactor, Bypass))
assert.True(t, IsAuthLevelSufficient(authentication.TwoFactor, Bypass))
assert.False(t, IsAuthLevelSufficient(authentication.NotAuthenticated, OneFactor))
assert.True(t, IsAuthLevelSufficient(authentication.OneFactor, OneFactor))
assert.True(t, IsAuthLevelSufficient(authentication.TwoFactor, OneFactor))
assert.False(t, IsAuthLevelSufficient(authentication.NotAuthenticated, TwoFactor))
assert.False(t, IsAuthLevelSufficient(authentication.OneFactor, TwoFactor))
assert.True(t, IsAuthLevelSufficient(authentication.TwoFactor, TwoFactor))
}

View File

@ -21,14 +21,14 @@ import (
) )
var ( var (
host string host string
validFrom string validFrom string
validFor time.Duration validFor time.Duration
isCA bool isCA bool
rsaBits int rsaBits int
ecdsaCurve string ecdsaCurve string
ed25519Key bool ed25519Key bool
targetDirectory string certificateTargetDirectory string
) )
func init() { func init() {
@ -45,7 +45,7 @@ func init() {
CertificatesGenerateCmd.PersistentFlags().IntVar(&rsaBits, "rsa-bits", 2048, "Size of RSA key to generate. Ignored if --ecdsa-curve is set") CertificatesGenerateCmd.PersistentFlags().IntVar(&rsaBits, "rsa-bits", 2048, "Size of RSA key to generate. Ignored if --ecdsa-curve is set")
CertificatesGenerateCmd.PersistentFlags().StringVar(&ecdsaCurve, "ecdsa-curve", "", "ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521") CertificatesGenerateCmd.PersistentFlags().StringVar(&ecdsaCurve, "ecdsa-curve", "", "ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521")
CertificatesGenerateCmd.PersistentFlags().BoolVar(&ed25519Key, "ed25519", false, "Generate an Ed25519 key") CertificatesGenerateCmd.PersistentFlags().BoolVar(&ed25519Key, "ed25519", false, "Generate an Ed25519 key")
CertificatesGenerateCmd.PersistentFlags().StringVar(&targetDirectory, "dir", "", "Target directory where the certificate and keys will be stored") CertificatesGenerateCmd.PersistentFlags().StringVar(&certificateTargetDirectory, "dir", "", "Target directory where the certificate and keys will be stored")
CertificatesCmd.AddCommand(CertificatesGenerateCmd) CertificatesCmd.AddCommand(CertificatesGenerateCmd)
} }
@ -144,7 +144,7 @@ func generateSelfSignedCertificate(cmd *cobra.Command, args []string) {
log.Fatalf("Failed to create certificate: %v", err) log.Fatalf("Failed to create certificate: %v", err)
} }
certPath := path.Join(targetDirectory, "cert.pem") certPath := path.Join(certificateTargetDirectory, "cert.pem")
certOut, err := os.Create(certPath) certOut, err := os.Create(certPath)
if err != nil { if err != nil {
@ -161,7 +161,7 @@ func generateSelfSignedCertificate(cmd *cobra.Command, args []string) {
log.Printf("wrote %s\n", certPath) log.Printf("wrote %s\n", certPath)
keyPath := path.Join(targetDirectory, "key.pem") keyPath := path.Join(certificateTargetDirectory, "key.pem")
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil { if err != nil {

View File

@ -0,0 +1,79 @@
package commands
import (
"log"
"os"
"path"
"github.com/spf13/cobra"
"github.com/authelia/authelia/internal/utils"
)
var rsaTargetDirectory string
func init() {
RSAGenerateCmd.PersistentFlags().StringVar(&rsaTargetDirectory, "dir", "", "Target directory where the keypair will be stored")
RSACmd.AddCommand(RSAGenerateCmd)
}
func generateRSAKeypair(cmd *cobra.Command, args []string) {
privateKey, publicKey := utils.GenerateRsaKeyPair(2048)
keyPath := path.Join(rsaTargetDirectory, "key.pem")
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
log.Fatalf("Failed to open %s for writing: %v", keyPath, err)
return
}
_, err = keyOut.WriteString(utils.ExportRsaPrivateKeyAsPemStr(privateKey))
if err != nil {
log.Fatalf("Unable to write private key: %v", err)
return
}
if err := keyOut.Close(); err != nil {
log.Fatalf("Unable to close private key file: %v", err)
return
}
keyPath = path.Join(rsaTargetDirectory, "key.pub")
keyOut, err = os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
log.Fatalf("Failed to open %s for writing: %v", keyPath, err)
return
}
publicPem, err := utils.ExportRsaPublicKeyAsPemStr(publicKey)
if err != nil {
log.Fatalf("Unable to marshal public key: %v", err)
}
_, err = keyOut.WriteString(publicPem)
if err != nil {
log.Fatalf("Unable to write private key: %v", err)
return
}
if err := keyOut.Close(); err != nil {
log.Fatalf("Unable to close public key file: %v", err)
return
}
}
// RSACmd RSA helper command.
var RSACmd = &cobra.Command{
Use: "rsa",
Short: "Commands related to rsa keypair generation",
}
// RSAGenerateCmd certificate generation command.
var RSAGenerateCmd = &cobra.Command{
Use: "generate",
Short: "Generate a RSA keypair",
Run: generateRSAKeypair,
}

View File

@ -553,4 +553,62 @@ notifier:
# sender: admin@example.com # sender: admin@example.com
# host: smtp.gmail.com # host: smtp.gmail.com
# port: 587 # port: 587
##
## Identity Providers
##
# identity_providers:
##
## OpenID Connect (Identity Provider)
##
## It's recommended you read the documentation before configuration of this section:
## https://www.authelia.com/docs/configuration/identity-providers/oidc.html
# oidc:
## The hmac_secret is used to sign OAuth2 tokens (authorization code, access tokens and refresh tokens).
## HMAC Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
# hmac_secret: this_is_a_secret_abc123abc123abc
## The issuer_private_key is used to sign the JWT forged by OpenID Connect.
## Issuer Private Key can also be set using a secret: https://docs.authelia.com/configuration/secrets.html
# issuer_private_key: |
# --- KEY START
# --- KEY END
## Clients is a list of known clients and their configuration.
# clients:
# -
## The ID is the OpenID Connect ClientID which is used to link an application to a configuration.
# id: myapp
## The description to show to users when they end up on the consent screen. Defaults to the ID above.
# description: My Application
## The client secret is a shared secret between Authelia and the consumer of this client.
# secret: this_is_a_secret
## The policy to require for this client; one_factor or two_factor.
# authorization_policy: two_factor
## Redirect URI's specifies a list of valid case-sensitive callbacks for this client.
# redirect_uris:
# - https://oidc.example.com:8080/oauth2/callback
## Scopes defines the valid scopes this client can request
# scopes:
# - openid
# - groups
# - email
# - profile
## Grant Types configures which grants this client can obtain.
## It's not recommended to define this unless you know what you're doing.
# grant_types:
# - refresh_token
# - "authorization_code
## 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
... ...

View File

@ -14,6 +14,7 @@ type Configuration struct {
JWTSecret string `mapstructure:"jwt_secret"` JWTSecret string `mapstructure:"jwt_secret"`
DefaultRedirectionURL string `mapstructure:"default_redirection_url"` DefaultRedirectionURL string `mapstructure:"default_redirection_url"`
IdentityProviders IdentityProvidersConfiguration `mapstructure:"identity_providers"`
AuthenticationBackend AuthenticationBackendConfiguration `mapstructure:"authentication_backend"` AuthenticationBackend AuthenticationBackendConfiguration `mapstructure:"authentication_backend"`
Session SessionConfiguration `mapstructure:"session"` Session SessionConfiguration `mapstructure:"session"`
TOTP *TOTPConfiguration `mapstructure:"totp"` TOTP *TOTPConfiguration `mapstructure:"totp"`

View File

@ -0,0 +1,35 @@
package schema
// IdentityProvidersConfiguration represents the IdentityProviders 2.0 configuration for Authelia.
type IdentityProvidersConfiguration struct {
OIDC *OpenIDConnectConfiguration `mapstructure:"oidc"`
}
// OpenIDConnectConfiguration configuration for OpenID Connect.
type OpenIDConnectConfiguration struct {
// This secret must be 32 bytes long
HMACSecret string `mapstructure:"hmac_secret"`
IssuerPrivateKey string `mapstructure:"issuer_private_key"`
Clients []OpenIDConnectClientConfiguration `mapstructure:"clients"`
}
// OpenIDConnectClientConfiguration configuration for an OpenID Connect client.
type OpenIDConnectClientConfiguration struct {
ID string `mapstructure:"id"`
Description string `mapstructure:"description"`
Secret string `mapstructure:"secret"`
RedirectURIs []string `mapstructure:"redirect_uris"`
Policy string `mapstructure:"authorization_policy"`
Scopes []string `mapstructure:"scopes"`
GrantTypes []string `mapstructure:"grant_types"`
ResponseTypes []string `mapstructure:"response_types"`
}
// 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",
}

View File

@ -91,4 +91,6 @@ func ValidateConfiguration(configuration *schema.Configuration, validator *schem
} else { } else {
ValidateNotifier(configuration.Notifier, validator) ValidateNotifier(configuration.Notifier, validator)
} }
ValidateIdentityProviders(&configuration.IdentityProviders, validator)
} }

View File

@ -7,6 +7,11 @@ const (
errFmtSessionRedisHostOrNodesRequired = "Either the host or a node 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"
errFmtReplacedConfigurationKey = "invalid configuration key '%s' was replaced by '%s'" errFmtReplacedConfigurationKey = "invalid configuration key '%s' was replaced by '%s'"
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"
errFileHashing = "config key incorrect: authentication_backend.file.hashing 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" 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" errFilePOptions = "config key incorrect: authentication_backend.file.password_options should be authentication_backend.file.password"
@ -42,15 +47,17 @@ var validRequestMethods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELET
// SecretNames contains a map of secret names. // SecretNames contains a map of secret names.
var SecretNames = map[string]string{ var SecretNames = map[string]string{
"JWTSecret": "jwt_secret", "JWTSecret": "jwt_secret",
"SessionSecret": "session.secret", "SessionSecret": "session.secret",
"DUOSecretKey": "duo_api.secret_key", "DUOSecretKey": "duo_api.secret_key",
"RedisPassword": "session.redis.password", "RedisPassword": "session.redis.password",
"RedisSentinelPassword": "session.redis.high_availability.sentinel_password", "RedisSentinelPassword": "session.redis.high_availability.sentinel_password",
"LDAPPassword": "authentication_backend.ldap.password", "LDAPPassword": "authentication_backend.ldap.password",
"SMTPPassword": "notifier.smtp.password", "SMTPPassword": "notifier.smtp.password",
"MySQLPassword": "storage.mysql.password", "MySQLPassword": "storage.mysql.password",
"PostgreSQLPassword": "storage.postgres.password", "PostgreSQLPassword": "storage.postgres.password",
"OpenIDConnectHMACSecret": "identity_providers.oidc.hmac_secret",
"OpenIDConnectIssuerPrivateKey": "identity_providers.oidc.issuer_private_key",
} }
// validKeys is a list of valid keys that are not secret names. For the sake of consistency please place any secret in // validKeys is a list of valid keys that are not secret names. For the sake of consistency please place any secret in
@ -184,6 +191,9 @@ var validKeys = []string{
"authentication_backend.file.password.salt_length", "authentication_backend.file.password.salt_length",
"authentication_backend.file.password.memory", "authentication_backend.file.password.memory",
"authentication_backend.file.password.parallelism", "authentication_backend.file.password.parallelism",
// Identity Provider Keys.
"identity_providers.oidc.clients",
} }
var replacedKeys = map[string]string{ var replacedKeys = map[string]string{

View File

@ -0,0 +1,98 @@
package validator
import (
"fmt"
"net/url"
"github.com/authelia/authelia/internal/configuration/schema"
"github.com/authelia/authelia/internal/utils"
)
// ValidateIdentityProviders validates and update IdentityProviders configuration.
func ValidateIdentityProviders(configuration *schema.IdentityProvidersConfiguration, validator *schema.StructValidator) {
validateOIDC(configuration.OIDC, validator)
}
func validateOIDC(configuration *schema.OpenIDConnectConfiguration, validator *schema.StructValidator) {
if configuration != nil {
if configuration.IssuerPrivateKey == "" {
validator.Push(fmt.Errorf("OIDC Server issuer private key must be provided"))
}
validateOIDCClients(configuration, validator)
if len(configuration.Clients) == 0 {
validator.Push(fmt.Errorf("OIDC Server has no clients defined"))
}
}
}
func validateOIDCClients(configuration *schema.OpenIDConnectConfiguration, validator *schema.StructValidator) {
invalidID, duplicateIDs := false, false
var ids []string
for c, client := range configuration.Clients {
if client.ID == "" {
invalidID = true
} else {
if client.Description == "" {
configuration.Clients[c].Description = client.ID
}
if utils.IsStringInSliceFold(client.ID, ids) {
duplicateIDs = true
}
ids = append(ids, client.ID)
}
if client.Secret == "" {
validator.Push(fmt.Errorf(errIdentityProvidersOIDCServerClientInvalidSecFmt, 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))
}
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
}
validateOIDCClientRedirectURIs(client, validator)
}
if invalidID {
validator.Push(fmt.Errorf("OIDC Server has one or more clients with an empty ID"))
}
if duplicateIDs {
validator.Push(fmt.Errorf("OIDC Server has clients with duplicate ID's"))
}
}
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))
break
}
if parsedURI.Scheme != "https" && parsedURI.Scheme != "http" {
validator.Push(fmt.Errorf(errOAuthOIDCServerClientRedirectURIFmt, redirectURI, parsedURI.Scheme))
}
}
}

View File

@ -0,0 +1,172 @@
package validator
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/authelia/authelia/internal/configuration/schema"
)
func TestShouldRaiseErrorWhenInvalidOIDCServerConfiguration(t *testing.T) {
validator := schema.NewStructValidator()
config := &schema.IdentityProvidersConfiguration{
OIDC: &schema.OpenIDConnectConfiguration{
HMACSecret: "abc",
IssuerPrivateKey: "",
},
}
ValidateIdentityProviders(config, validator)
require.Len(t, validator.Errors(), 2)
assert.EqualError(t, validator.Errors()[0], "OIDC Server issuer private key must be provided")
assert.EqualError(t, validator.Errors()[1], "OIDC Server has no clients defined")
}
func TestShouldRaiseErrorWhenOIDCServerIssuerPrivateKeyPathInvalid(t *testing.T) {
validator := schema.NewStructValidator()
config := &schema.IdentityProvidersConfiguration{
OIDC: &schema.OpenIDConnectConfiguration{
HMACSecret: "rLABDrx87et5KvRHVUgTm3pezWWd8LMN",
IssuerPrivateKey: "key-material",
},
}
ValidateIdentityProviders(config, validator)
require.Len(t, validator.Errors(), 1)
assert.EqualError(t, validator.Errors()[0], "OIDC Server has no clients defined")
}
func TestShouldRaiseErrorWhenOIDCServerClientBadValues(t *testing.T) {
validator := schema.NewStructValidator()
config := &schema.IdentityProvidersConfiguration{
OIDC: &schema.OpenIDConnectConfiguration{
HMACSecret: "rLABDrx87et5KvRHVUgTm3pezWWd8LMN",
IssuerPrivateKey: "key-material",
Clients: []schema.OpenIDConnectClientConfiguration{
{
ID: "",
Secret: "",
Policy: "",
RedirectURIs: []string{
"tcp://google.com",
},
},
{
ID: "a-client",
Secret: "a-secret",
Policy: "a-policy",
RedirectURIs: []string{
"https://google.com",
},
},
{
ID: "a-client",
Secret: "a-secret",
Policy: "a-policy",
RedirectURIs: []string{
"https://google.com",
},
},
{
ID: "client-check-uri-parse",
Secret: "a-secret",
Policy: twoFactorPolicy,
RedirectURIs: []string{
"http://abc@%two",
},
},
},
},
}
ValidateIdentityProviders(config, validator)
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()[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) {
validator := schema.NewStructValidator()
config := &schema.IdentityProvidersConfiguration{
OIDC: &schema.OpenIDConnectConfiguration{
HMACSecret: "rLABDrx87et5KvRHVUgTm3pezWWd8LMN",
IssuerPrivateKey: "../../../README.md",
Clients: []schema.OpenIDConnectClientConfiguration{
{
ID: "a-client",
Secret: "a-client-secret",
Policy: oneFactorPolicy,
RedirectURIs: []string{
"https://google.com",
},
},
{
ID: "b-client",
Description: "Normal Description",
Secret: "b-client-secret",
Policy: oneFactorPolicy,
RedirectURIs: []string{
"https://google.com",
},
Scopes: []string{
"groups",
},
GrantTypes: []string{
"refresh_token",
},
ResponseTypes: []string{
"token",
"code",
},
},
},
},
}
ValidateIdentityProviders(config, validator)
assert.Len(t, validator.Errors(), 0)
assert.Equal(t, config.OIDC.Clients[0].ID, config.OIDC.Clients[0].Description)
assert.Equal(t, "Normal Description", config.OIDC.Clients[1].Description)
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])
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])
require.Len(t, config.OIDC.Clients[1].GrantTypes, 1)
assert.Equal(t, "refresh_token", config.OIDC.Clients[1].GrantTypes[0])
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])
}

View File

@ -58,6 +58,11 @@ func ValidateSecrets(configuration *schema.Configuration, validator *schema.Stru
if configuration.Storage.PostgreSQL != nil { if configuration.Storage.PostgreSQL != nil {
configuration.Storage.PostgreSQL.Password = getSecretValue(SecretNames["PostgreSQLPassword"], validator, viper) configuration.Storage.PostgreSQL.Password = getSecretValue(SecretNames["PostgreSQLPassword"], validator, viper)
} }
if configuration.IdentityProviders.OIDC != nil {
configuration.IdentityProviders.OIDC.HMACSecret = getSecretValue(SecretNames["OpenIDConnectHMACSecret"], validator, viper)
configuration.IdentityProviders.OIDC.IssuerPrivateKey = getSecretValue(SecretNames["OpenIDConnectIssuerPrivateKey"], validator, viper)
}
} }
func getSecretValue(name string, validator *schema.StructValidator, viper *viper.Viper) string { func getSecretValue(name string, validator *schema.StructValidator, viper *viper.Viper) string {
@ -75,7 +80,8 @@ func getSecretValue(name string, validator *schema.StructValidator, viper *viper
if err != nil { if err != nil {
validator.Push(fmt.Errorf("error loading secret file (%s): %s", name, err)) validator.Push(fmt.Errorf("error loading secret file (%s): %s", name, err))
} else { } else {
return strings.ReplaceAll(string(content), "\n", "") // TODO: Test this functionality.
return strings.TrimRight(string(content), "\n")
} }
} }

View File

@ -25,8 +25,6 @@ const remoteNameHeader = "Remote-Name"
const remoteEmailHeader = "Remote-Email" const remoteEmailHeader = "Remote-Email"
const remoteGroupsHeader = "Remote-Groups" const remoteGroupsHeader = "Remote-Groups"
var protoHostSeparator = []byte("://")
const ( const (
// Forbidden means the user is forbidden the access to a resource. // Forbidden means the user is forbidden the access to a resource.
Forbidden authorizationMatching = iota Forbidden authorizationMatching = iota
@ -57,3 +55,30 @@ const testUsername = "john"
const movingAverageWindow = 10 const movingAverageWindow = 10
const msMinimumDelay1FA = float64(250) const msMinimumDelay1FA = float64(250)
const msMaximumRandomDelay = int64(85) 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.
oidcIntrospectPath = "/api/oidc/introspect"
oidcRevokePath = "/api/oidc/revoke"
// Note: If you change this const you must also do so in the frontend at web/src/services/Api.ts.
oidcConsentPath = "/api/oidc/consent"
)
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

@ -127,8 +127,12 @@ func FirstFactorPost(msInitialDelay time.Duration, delayEnabled bool) middleware
ctx.Logger.Debugf("Credentials validation of user %s is ok", bodyJSON.Username) ctx.Logger.Debugf("Credentials validation of user %s is ok", bodyJSON.Username)
// Reset all values from previous session before regenerating the cookie. userSession := ctx.GetSession()
err = ctx.SaveSession(session.NewDefaultUserSession()) newSession := session.NewDefaultUserSession()
newSession.OIDCWorkflowSession = userSession.OIDCWorkflowSession
// Reset all values from previous session except OIDC workflow before regenerating the cookie.
err = ctx.SaveSession(newSession)
if err != nil { if err != nil {
handleAuthenticationUnauthorized(ctx, fmt.Errorf("Unable to reset the session for user %s: %s", bodyJSON.Username, err.Error()), authenticationFailedMessage) handleAuthenticationUnauthorized(ctx, fmt.Errorf("Unable to reset the session for user %s: %s", bodyJSON.Username, err.Error()), authenticationFailedMessage)
@ -165,7 +169,6 @@ 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) 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. // And set those information in the new session.
userSession := ctx.GetSession()
userSession.Username = userDetails.Username userSession.Username = userDetails.Username
userSession.DisplayName = userDetails.DisplayName userSession.DisplayName = userDetails.DisplayName
userSession.Groups = userDetails.Groups userSession.Groups = userDetails.Groups
@ -188,6 +191,10 @@ func FirstFactorPost(msInitialDelay time.Duration, delayEnabled bool) middleware
successful = true successful = true
Handle1FAResponse(ctx, bodyJSON.TargetURL, bodyJSON.RequestMethod, userSession.Username, userSession.Groups) if userSession.OIDCWorkflowSession != nil {
HandleOIDCWorkflowResponse(ctx)
} else {
Handle1FAResponse(ctx, bodyJSON.TargetURL, bodyJSON.RequestMethod, userSession.Username, userSession.Groups)
}
} }
} }

View File

@ -0,0 +1,129 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"github.com/ory/fosite"
"github.com/authelia/authelia/internal/logging"
"github.com/authelia/authelia/internal/middlewares"
"github.com/authelia/authelia/internal/oidc"
"github.com/authelia/authelia/internal/session"
)
func oidcAuthorize(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *http.Request) {
ar, err := ctx.Providers.OpenIDConnect.Fosite.NewAuthorizeRequest(ctx, r)
if err != nil {
logging.Logger().Errorf("Error occurred in NewAuthorizeRequest: %+v", err)
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, ar, err)
return
}
clientID := ar.GetClient().GetID()
client, err := ctx.Providers.OpenIDConnect.Store.GetInternalClient(clientID)
if err != nil {
err := fmt.Errorf("Unable to find related client configuration with name '%s': %v", ar.GetID(), err)
ctx.Logger.Error(err)
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, ar, err)
return
}
userSession := ctx.GetSession()
requestedScopes := ar.GetRequestedScopes()
requestedAudience := ar.GetRequestedAudience()
isAuthInsufficient := !client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel)
if isAuthInsufficient || (isConsentMissing(userSession.OIDCWorkflowSession, requestedScopes, requestedAudience)) {
oidcAuthorizeHandleAuthorizationOrConsentInsufficient(ctx, userSession, client, isAuthInsufficient, rw, r, ar)
return
}
for _, scope := range requestedScopes {
ar.GrantScope(scope)
}
for _, a := range requestedAudience {
ar.GrantAudience(a)
}
userSession.OIDCWorkflowSession = nil
if err := ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf("%v", err)
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
oauthSession, err := newOIDCSession(ctx, ar)
if err != nil {
ctx.Logger.Errorf("Error occurred in NewOIDCSession: %+v", err)
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, ar, err)
return
}
response, err := ctx.Providers.OpenIDConnect.Fosite.NewAuthorizeResponse(ctx, ar, oauthSession)
if err != nil {
ctx.Logger.Errorf("Error occurred in NewAuthorizeResponse: %+v", err)
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, ar, err)
return
}
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeResponse(rw, ar, response)
}
func oidcAuthorizeHandleAuthorizationOrConsentInsufficient(
ctx *middlewares.AutheliaCtx, userSession session.UserSession, client *oidc.InternalClient, isAuthInsufficient bool,
rw http.ResponseWriter, r *http.Request,
ar fosite.AuthorizeRequester) {
forwardedProtoHost, err := ctx.ForwardedProtoHost()
if err != nil {
ctx.Logger.Errorf("%v", err)
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
redirectURL := fmt.Sprintf("%s%s", forwardedProtoHost, string(ctx.Request.RequestURI()))
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
if err := ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf("%v", err)
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
uri, err := ctx.ForwardedProtoHost()
if err != nil {
ctx.Logger.Errorf("%v", err)
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if isAuthInsufficient {
http.Redirect(rw, r, uri, http.StatusFound)
} else {
http.Redirect(rw, r, fmt.Sprintf("%s/consent", uri), http.StatusFound)
}
}

View File

@ -0,0 +1,124 @@
package handlers
import (
"encoding/json"
"fmt"
"github.com/authelia/authelia/internal/middlewares"
)
func oidcConsent(ctx *middlewares.AutheliaCtx) {
userSession := ctx.GetSession()
if userSession.OIDCWorkflowSession == nil {
ctx.Logger.Debugf("Cannot consent for user %s when OIDC workflow has not been initiated", userSession.Username)
ctx.ReplyForbidden()
return
}
clientID := userSession.OIDCWorkflowSession.ClientID
client, err := ctx.Providers.OpenIDConnect.Store.GetInternalClient(clientID)
if err != nil {
ctx.Logger.Debugf("Unable to find related client configuration with name '%s': %v", clientID, err)
ctx.ReplyForbidden()
return
}
if !client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel) {
ctx.Logger.Debugf("Insufficient permissions to give consent v2 %d -> %d", userSession.AuthenticationLevel, userSession.OIDCWorkflowSession.RequiredAuthorizationLevel)
ctx.ReplyForbidden()
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 {
ctx.Error(fmt.Errorf("Unable to set JSON body: %v", err), "Operation failed")
}
}
func oidcConsentPOST(ctx *middlewares.AutheliaCtx) {
userSession := ctx.GetSession()
if userSession.OIDCWorkflowSession == nil {
ctx.Logger.Debugf("Cannot consent for user %s when OIDC workflow has not been initiated", userSession.Username)
ctx.ReplyForbidden()
return
}
client, err := ctx.Providers.OpenIDConnect.Store.GetInternalClient(userSession.OIDCWorkflowSession.ClientID)
if err != nil {
ctx.Logger.Debugf("Unable to find related client configuration with name '%s': %v", userSession.OIDCWorkflowSession.ClientID, err)
ctx.ReplyForbidden()
return
}
if !client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel) {
ctx.Logger.Debugf("Insufficient permissions to give consent v1 %d -> %d", userSession.AuthenticationLevel, userSession.OIDCWorkflowSession.RequiredAuthorizationLevel)
ctx.ReplyForbidden()
return
}
var body ConsentPostRequestBody
err = json.Unmarshal(ctx.Request.Body(), &body)
if err != nil {
ctx.Error(fmt.Errorf("Unable to unmarshal body: %v", err), "Operation failed")
return
}
if body.AcceptOrReject != accept && body.AcceptOrReject != reject {
ctx.Logger.Infof("User %s tried to reply to consent with an unexpected verb", userSession.Username)
ctx.ReplyBadRequest()
return
}
if userSession.OIDCWorkflowSession.ClientID != body.ClientID {
ctx.Logger.Infof("User %s consented to scopes of another client (%s) than expected (%s). Beware this can be a sign of attack",
userSession.Username, body.ClientID, userSession.OIDCWorkflowSession.ClientID)
ctx.ReplyBadRequest()
return
}
var redirectionURL string
if body.AcceptOrReject == accept {
redirectionURL = userSession.OIDCWorkflowSession.AuthURI
userSession.OIDCWorkflowSession.GrantedScopes = userSession.OIDCWorkflowSession.RequestedScopes
userSession.OIDCWorkflowSession.GrantedAudience = userSession.OIDCWorkflowSession.RequestedAudience
if err := ctx.SaveSession(userSession); err != nil {
ctx.Error(fmt.Errorf("Unable to write session: %v", err), "Operation failed")
return
}
} else if body.AcceptOrReject == reject {
redirectionURL = fmt.Sprintf("%s?error=access_denied&error_description=%s",
userSession.OIDCWorkflowSession.TargetURI, "User has rejected the scopes")
userSession.OIDCWorkflowSession = nil
if err := ctx.SaveSession(userSession); err != nil {
ctx.Error(fmt.Errorf("Unable to write session: %v", err), "Operation failed")
return
}
}
response := ConsentPostResponseBody{RedirectURI: redirectionURL}
if err := ctx.SetJSONBody(response); err != nil {
ctx.Error(fmt.Errorf("Unable to set JSON body in response"), "Operation failed")
}
}

View File

@ -0,0 +1,29 @@
package handlers
import (
"net/http"
"github.com/authelia/authelia/internal/middlewares"
)
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
}
ir, err := ctx.Providers.OpenIDConnect.Fosite.NewIntrospectionRequest(ctx, req, oidcSession)
if err != nil {
ctx.Logger.Errorf("Error occurred in NewIntrospectionRequest: %+v", err)
ctx.Providers.OpenIDConnect.Fosite.WriteIntrospectionError(rw, err)
return
}
ctx.Providers.OpenIDConnect.Fosite.WriteIntrospectionResponse(rw, ir)
}

View File

@ -0,0 +1,15 @@
package handlers
import (
"encoding/json"
"github.com/authelia/authelia/internal/middlewares"
)
func oidcJWKs(ctx *middlewares.AutheliaCtx) {
ctx.SetContentType("application/json")
if err := json.NewEncoder(ctx).Encode(ctx.Providers.OpenIDConnect.GetKeySet()); err != nil {
ctx.Error(err, "failed to serve jwk set")
}
}

View File

@ -0,0 +1,13 @@
package handlers
import (
"net/http"
"github.com/authelia/authelia/internal/middlewares"
)
func oidcRevoke(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, req *http.Request) {
err := ctx.Providers.OpenIDConnect.Fosite.NewRevocationRequest(ctx, req)
ctx.Providers.OpenIDConnect.Fosite.WriteRevocationResponse(rw, err)
}

View File

@ -0,0 +1,46 @@
package handlers
import (
"net/http"
"github.com/ory/fosite"
"github.com/authelia/authelia/internal/middlewares"
)
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
}
accessRequest, accessReqErr := ctx.Providers.OpenIDConnect.Fosite.NewAccessRequest(ctx, req, oidcSession)
if accessReqErr != nil {
ctx.Logger.Errorf("Error occurred in NewAccessRequest: %+v", accessRequest)
ctx.Providers.OpenIDConnect.Fosite.WriteAccessError(rw, accessRequest, accessReqErr)
return
}
// If this is a client_credentials grant, grant all scopes the client is allowed to perform.
if accessRequest.GetGrantTypes().ExactOne("client_credentials") {
for _, scope := range accessRequest.GetRequestedScopes() {
if fosite.HierarchicScopeStrategy(accessRequest.GetClient().GetScopes(), scope) {
accessRequest.GrantScope(scope)
}
}
}
response, err := ctx.Providers.OpenIDConnect.Fosite.NewAccessResponse(ctx, accessRequest)
if err != nil {
ctx.Logger.Errorf("Error occurred in NewAccessResponse: %+v", err)
ctx.Providers.OpenIDConnect.Fosite.WriteAccessError(rw, accessRequest, err)
return
}
ctx.Providers.OpenIDConnect.Fosite.WriteAccessResponse(rw, accessRequest, response)
}

View File

@ -0,0 +1,73 @@
package handlers
import (
"encoding/json"
"fmt"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/internal/middlewares"
)
func oidcWellKnown(ctx *middlewares.AutheliaCtx) {
var configuration WellKnownConfigurationJSON
issuer, err := ctx.ForwardedProtoHost()
if err != nil {
ctx.Logger.Errorf("Error occurred in ForwardedProtoHost: %+v", err)
ctx.Response.SetStatusCode(fasthttp.StatusBadRequest)
return
}
configuration.Issuer = issuer
configuration.AuthURL = fmt.Sprintf("%s%s", issuer, oidcAuthorizePath)
configuration.TokenURL = fmt.Sprintf("%s%s", issuer, oidcTokenPath)
configuration.RevocationEndpoint = fmt.Sprintf("%s%s", issuer, oidcRevokePath)
configuration.JWKSURL = fmt.Sprintf("%s%s", issuer, oidcJWKsPath)
configuration.Algorithms = []string{"RS256"}
configuration.ScopesSupported = []string{
"openid",
"profile",
"groups",
"email",
// Determine if this is really mandatory knowing the RP can request for a refresh token through the authorize
// endpoint anyway.
"offline_access",
}
configuration.ClaimsSupported = []string{
"aud",
"exp",
"iat",
"iss",
"jti",
"rat",
"sub",
"auth_time",
"nonce",
"email",
"email_verified",
"groups",
"name",
}
configuration.ResponseTypesSupported = []string{
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none",
}
ctx.SetContentType("application/json")
if err := json.NewEncoder(ctx).Encode(configuration); 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)
return
}
}

View File

@ -73,6 +73,10 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
return return
} }
Handle2FAResponse(ctx, requestBody.TargetURL) if userSession.OIDCWorkflowSession != nil {
HandleOIDCWorkflowResponse(ctx)
} else {
Handle2FAResponse(ctx, requestBody.TargetURL)
}
} }
} }

View File

@ -10,8 +10,8 @@ import (
// SecondFactorTOTPPost validate the TOTP passcode provided by the user. // SecondFactorTOTPPost validate the TOTP passcode provided by the user.
func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler { func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler {
return func(ctx *middlewares.AutheliaCtx) { return func(ctx *middlewares.AutheliaCtx) {
bodyJSON := signTOTPRequestBody{} requestBody := signTOTPRequestBody{}
err := ctx.ParseBody(&bodyJSON) err := ctx.ParseBody(&requestBody)
if err != nil { if err != nil {
handleAuthenticationUnauthorized(ctx, err, mfaValidationFailedMessage) handleAuthenticationUnauthorized(ctx, err, mfaValidationFailedMessage)
@ -26,7 +26,7 @@ func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler
return return
} }
isValid, err := totpVerifier.Verify(bodyJSON.Token, secret) isValid, err := totpVerifier.Verify(requestBody.Token, secret)
if err != nil { if err != nil {
handleAuthenticationUnauthorized(ctx, fmt.Errorf("Error occurred during OTP validation for user %s: %s", userSession.Username, err), mfaValidationFailedMessage) handleAuthenticationUnauthorized(ctx, fmt.Errorf("Error occurred during OTP validation for user %s: %s", userSession.Username, err), mfaValidationFailedMessage)
return return
@ -52,6 +52,10 @@ func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler
return return
} }
Handle2FAResponse(ctx, bodyJSON.TargetURL) if userSession.OIDCWorkflowSession != nil {
HandleOIDCWorkflowResponse(ctx)
} else {
Handle2FAResponse(ctx, requestBody.TargetURL)
}
} }
} }

View File

@ -55,6 +55,10 @@ func SecondFactorU2FSignPost(u2fVerifier U2FVerifier) middlewares.RequestHandler
return return
} }
Handle2FAResponse(ctx, requestBody.TargetURL) if userSession.OIDCWorkflowSession != nil {
HandleOIDCWorkflowResponse(ctx)
} else {
Handle2FAResponse(ctx, requestBody.TargetURL)
}
} }
} }

View File

@ -31,49 +31,6 @@ func isSchemeWSS(url *url.URL) bool {
return url.Scheme == "wss" return url.Scheme == "wss"
} }
// getOriginalURL extract the URL from the request headers (X-Original-URI or X-Forwarded-* headers).
func getOriginalURL(ctx *middlewares.AutheliaCtx) (*url.URL, error) {
originalURL := ctx.XOriginalURL()
if originalURL != nil {
url, err := url.ParseRequestURI(string(originalURL))
if err != nil {
return nil, fmt.Errorf("Unable to parse URL extracted from X-Original-URL header: %v", err)
}
ctx.Logger.Trace("Using X-Original-URL header content as targeted site URL")
return url, nil
}
forwardedProto := ctx.XForwardedProto()
forwardedHost := ctx.XForwardedHost()
forwardedURI := ctx.XForwardedURI()
if forwardedProto == nil {
return nil, errMissingXForwardedProto
}
if forwardedHost == nil {
return nil, errMissingXForwardedHost
}
var requestURI string
scheme := append(forwardedProto, protoHostSeparator...)
requestURI = string(append(scheme,
append(forwardedHost, forwardedURI...)...))
url, err := url.ParseRequestURI(requestURI)
if err != nil {
return nil, fmt.Errorf("Unable to parse URL %s: %v", requestURI, err)
}
ctx.Logger.Tracef("Using X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-URI headers " +
"to construct targeted site URL")
return url, nil
}
// parseBasicAuth parses an HTTP Basic Authentication string. // parseBasicAuth parses an HTTP Basic Authentication string.
// "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true). // "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true).
func parseBasicAuth(header, auth string) (username, password string, err error) { func parseBasicAuth(header, auth string) (username, password string, err error) {
@ -468,7 +425,7 @@ func VerifyGet(cfg schema.AuthenticationBackendConfiguration) middlewares.Reques
return func(ctx *middlewares.AutheliaCtx) { return func(ctx *middlewares.AutheliaCtx) {
ctx.Logger.Tracef("Headers=%s", ctx.Request.Header.String()) ctx.Logger.Tracef("Headers=%s", ctx.Request.Header.String())
targetURL, err := getOriginalURL(ctx) targetURL, err := ctx.GetOriginalURL()
if err != nil { if err != nil {
ctx.Error(fmt.Errorf("Unable to parse target URL: %s", err), operationFailedMessage) ctx.Error(fmt.Errorf("Unable to parse target URL: %s", err), operationFailedMessage)

View File

@ -26,49 +26,13 @@ var verifyGetCfg = schema.AuthenticationBackendConfiguration{
LDAP: &schema.LDAPAuthenticationBackendConfiguration{}, LDAP: &schema.LDAPAuthenticationBackendConfiguration{},
} }
// Test getOriginalURL.
func TestShouldGetOriginalURLFromOriginalURLHeader(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
mock.Ctx.Request.Header.Set("X-Original-URL", "https://home.example.com")
originalURL, err := getOriginalURL(mock.Ctx)
assert.NoError(t, err)
expectedURL, err := url.ParseRequestURI("https://home.example.com")
assert.NoError(t, err)
assert.Equal(t, expectedURL, originalURL)
}
func TestShouldGetOriginalURLFromForwardedHeadersWithoutURI(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https")
mock.Ctx.Request.Header.Set("X-Forwarded-Host", "home.example.com")
originalURL, err := getOriginalURL(mock.Ctx)
assert.NoError(t, err)
expectedURL, err := url.ParseRequestURI("https://home.example.com")
assert.NoError(t, err)
assert.Equal(t, expectedURL, originalURL)
}
func TestShouldGetOriginalURLFromForwardedHeadersWithURI(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
mock.Ctx.Request.Header.Set("X-Original-URL", "htt-ps//home?-.example.com")
_, err := getOriginalURL(mock.Ctx)
assert.Error(t, err)
assert.Equal(t, "Unable to parse URL extracted from X-Original-URL header: parse \"htt-ps//home?-.example.com\": invalid URI for request", err.Error())
}
func TestShouldRaiseWhenTargetUrlIsMalformed(t *testing.T) { func TestShouldRaiseWhenTargetUrlIsMalformed(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t) mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close() defer mock.Close()
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https") mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https")
mock.Ctx.Request.Header.Set("X-Forwarded-Host", "home.example.com") mock.Ctx.Request.Header.Set("X-Forwarded-Host", "home.example.com")
mock.Ctx.Request.Header.Set("X-Forwarded-URI", "/abc") mock.Ctx.Request.Header.Set("X-Forwarded-URI", "/abc")
originalURL, err := getOriginalURL(mock.Ctx) originalURL, err := mock.Ctx.GetOriginalURL()
assert.NoError(t, err) assert.NoError(t, err)
expectedURL, err := url.ParseRequestURI("https://home.example.com/abc") expectedURL, err := url.ParseRequestURI("https://home.example.com/abc")
@ -79,7 +43,7 @@ func TestShouldRaiseWhenTargetUrlIsMalformed(t *testing.T) {
func TestShouldRaiseWhenNoHeaderProvidedToDetectTargetURL(t *testing.T) { func TestShouldRaiseWhenNoHeaderProvidedToDetectTargetURL(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t) mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close() defer mock.Close()
_, err := getOriginalURL(mock.Ctx) _, err := mock.Ctx.GetOriginalURL()
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, "Missing header X-Forwarded-Proto", err.Error()) assert.Equal(t, "Missing header X-Forwarded-Proto", err.Error())
} }
@ -89,7 +53,7 @@ func TestShouldRaiseWhenNoXForwardedHostHeaderProvidedToDetectTargetURL(t *testi
defer mock.Close() defer mock.Close()
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https") mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https")
_, err := getOriginalURL(mock.Ctx) _, err := mock.Ctx.GetOriginalURL()
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, "Missing header X-Forwarded-Host", err.Error()) assert.Equal(t, "Missing header X-Forwarded-Host", err.Error())
} }
@ -101,7 +65,7 @@ func TestShouldRaiseWhenXForwardedProtoIsNotParsable(t *testing.T) {
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "!:;;:,") mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "!:;;:,")
mock.Ctx.Request.Header.Set("X-Forwarded-Host", "myhost.local") mock.Ctx.Request.Header.Set("X-Forwarded-Host", "myhost.local")
_, err := getOriginalURL(mock.Ctx) _, err := mock.Ctx.GetOriginalURL()
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, "Unable to parse URL !:;;:,://myhost.local: parse \"!:;;:,://myhost.local\": invalid URI for request", err.Error()) assert.Equal(t, "Unable to parse URL !:;;:,://myhost.local: parse \"!:;;:,://myhost.local\": invalid URI for request", err.Error())
} }
@ -114,7 +78,7 @@ func TestShouldRaiseWhenXForwardedURIIsNotParsable(t *testing.T) {
mock.Ctx.Request.Header.Set("X-Forwarded-Host", "myhost.local") mock.Ctx.Request.Header.Set("X-Forwarded-Host", "myhost.local")
mock.Ctx.Request.Header.Set("X-Forwarded-URI", "!:;;:,") mock.Ctx.Request.Header.Set("X-Forwarded-URI", "!:;;:,")
_, err := getOriginalURL(mock.Ctx) _, err := mock.Ctx.GetOriginalURL()
require.Error(t, err) require.Error(t, err)
assert.Equal(t, "Unable to parse URL https://myhost.local!:;;:,: parse \"https://myhost.local!:;;:,\": invalid port \":,\" after host", err.Error()) assert.Equal(t, "Unable to parse URL https://myhost.local!:;;:,: parse \"https://myhost.local!:;;:,\": invalid port \":,\" after host", err.Error())
} }

View File

@ -0,0 +1,106 @@
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/session"
"github.com/authelia/authelia/internal/utils"
)
// isConsentMissing compares the requestedScopes and requestedAudience to the workflows
// GrantedScopes and GrantedAudience and returns true if they do not match or the workflow is nil.
func isConsentMissing(workflow *session.OIDCWorkflowSession, requestedScopes, requestedAudience []string) (isMissing bool) {
if workflow == nil {
return true
}
return len(requestedScopes) > 0 && utils.IsStringSlicesDifferent(requestedScopes, workflow.GrantedScopes) ||
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{}),
},
Headers: &jwt.Headers{
Extra: make(map[string]interface{}),
},
}, err
}

View File

@ -0,0 +1,33 @@
package handlers
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/authelia/authelia/internal/session"
)
func TestShouldDetectIfConsentIsMissing(t *testing.T) {
var workflow *session.OIDCWorkflowSession
requestedScopes := []string{"openid", "profile"}
requestedAudience := []string{"https://authelia.com"}
assert.True(t, isConsentMissing(workflow, requestedScopes, requestedAudience))
workflow = &session.OIDCWorkflowSession{
GrantedScopes: []string{"openid", "profile"},
GrantedAudience: []string{"https://authelia.com"},
}
assert.False(t, isConsentMissing(workflow, requestedScopes, requestedAudience))
requestedScopes = []string{"openid", "profile", "group"}
assert.True(t, isConsentMissing(workflow, requestedScopes, requestedAudience))
requestedScopes = []string{"openid", "profile"}
requestedAudience = []string{"https://not.authelia.com"}
assert.True(t, isConsentMissing(workflow, requestedScopes, requestedAudience))
}

View File

@ -0,0 +1,29 @@
package handlers
import (
"github.com/fasthttp/router"
"github.com/authelia/authelia/internal/middlewares"
)
// 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(oidcConsentPath, middleware(oidcConsent))
router.POST(oidcConsentPath, middleware(oidcConsentPOST))
router.GET(oidcJWKsPath, middleware(oidcJWKs))
router.GET(oidcAuthorizePath, middleware(middlewares.NewHTTPToAutheliaHandlerAdaptor(oidcAuthorize)))
// TODO: Add OPTIONS handler.
router.POST(oidcTokenPath, middleware(middlewares.NewHTTPToAutheliaHandlerAdaptor(oidcToken)))
router.POST(oidcIntrospectPath, middleware(middlewares.NewHTTPToAutheliaHandlerAdaptor(oidcIntrospect)))
// TODO: Add OPTIONS handler.
router.POST(oidcRevokePath, middleware(middlewares.NewHTTPToAutheliaHandlerAdaptor(oidcRevoke)))
}

View File

@ -11,6 +11,42 @@ import (
"github.com/authelia/authelia/internal/utils" "github.com/authelia/authelia/internal/utils"
) )
// 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) {
ctx.Logger.Warn("OIDC requires 2FA, cannot be redirected yet")
ctx.ReplyOK()
return
}
uri, err := ctx.ForwardedProtoHost()
if err != nil {
ctx.Logger.Errorf("%v", err)
handleAuthenticationUnauthorized(ctx, fmt.Errorf("Unable to get forward facing URI"), authenticationFailedMessage)
return
}
if isConsentMissing(
userSession.OIDCWorkflowSession,
userSession.OIDCWorkflowSession.RequestedScopes,
userSession.OIDCWorkflowSession.RequestedAudience) {
err := ctx.SetJSONBody(redirectResponse{Redirect: fmt.Sprintf("%s/consent", uri)})
if err != nil {
ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err)
}
} else {
err := ctx.SetJSONBody(redirectResponse{Redirect: userSession.OIDCWorkflowSession.AuthURI})
if err != nil {
ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err)
}
}
}
// Handle1FAResponse handle the redirection upon 1FA authentication. // Handle1FAResponse handle the redirection upon 1FA authentication.
func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI, requestMethod string, username string, groups []string) { func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI, requestMethod string, username string, groups []string) {
if targetURI == "" { if targetURI == "" {

View File

@ -0,0 +1,62 @@
package handlers
import (
"github.com/dgrijalva/jwt-go"
)
// ConsentPostRequestBody schema of the request body of the consent POST endpoint.
type ConsentPostRequestBody struct {
ClientID string `json:"client_id"`
AcceptOrReject string `json:"accept_or_reject"`
}
// ConsentPostResponseBody schema of the response body of the consent POST endpoint.
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"`
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

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"net/url"
"strings" "strings"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
@ -37,7 +38,7 @@ func NewAutheliaCtx(ctx *fasthttp.RequestCtx, configuration schema.Configuration
} }
// AutheliaMiddleware is wrapping the RequestCtx into an AutheliaCtx providing Authelia related objects. // AutheliaMiddleware is wrapping the RequestCtx into an AutheliaCtx providing Authelia related objects.
func AutheliaMiddleware(configuration schema.Configuration, providers Providers) func(next RequestHandler) fasthttp.RequestHandler { func AutheliaMiddleware(configuration schema.Configuration, providers Providers) RequestHandlerBridge {
return func(next RequestHandler) fasthttp.RequestHandler { return func(next RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) { return func(ctx *fasthttp.RequestCtx) {
autheliaCtx, err := NewAutheliaCtx(ctx, configuration, providers) autheliaCtx, err := NewAutheliaCtx(ctx, configuration, providers)
@ -87,6 +88,11 @@ func (c *AutheliaCtx) ReplyForbidden() {
c.RequestCtx.Error(fasthttp.StatusMessage(fasthttp.StatusForbidden), fasthttp.StatusForbidden) c.RequestCtx.Error(fasthttp.StatusMessage(fasthttp.StatusForbidden), fasthttp.StatusForbidden)
} }
// ReplyBadRequest response sent when bad request has been sent.
func (c *AutheliaCtx) ReplyBadRequest() {
c.RequestCtx.Error(fasthttp.StatusMessage(fasthttp.StatusBadRequest), fasthttp.StatusBadRequest)
}
// XForwardedProto return the content of the X-Forwarded-Proto header. // XForwardedProto return the content of the X-Forwarded-Proto header.
func (c *AutheliaCtx) XForwardedProto() []byte { func (c *AutheliaCtx) XForwardedProto() []byte {
return c.RequestCtx.Request.Header.Peek(xForwardedProtoHeader) return c.RequestCtx.Request.Header.Peek(xForwardedProtoHeader)
@ -107,6 +113,24 @@ func (c *AutheliaCtx) XForwardedURI() []byte {
return c.RequestCtx.Request.Header.Peek(xForwardedURIHeader) return c.RequestCtx.Request.Header.Peek(xForwardedURIHeader)
} }
// ForwardedProtoHost gets the X-Forwarded-Proto and X-Forwarded-Host headers and forms them into a URL.
func (c AutheliaCtx) ForwardedProtoHost() (string, error) {
XForwardedProto := c.XForwardedProto()
if XForwardedProto == nil {
return "", errMissingXForwardedProto
}
XForwardedHost := c.XForwardedHost()
if XForwardedHost == nil {
return "", errMissingXForwardedHost
}
return fmt.Sprintf("%s://%s", XForwardedProto,
XForwardedHost), nil
}
// XOriginalURL return the content of the X-Original-URL header. // XOriginalURL return the content of the X-Original-URL header.
func (c *AutheliaCtx) XOriginalURL() []byte { func (c *AutheliaCtx) XOriginalURL() []byte {
return c.RequestCtx.Request.Header.Peek(xOriginalURLHeader) return c.RequestCtx.Request.Header.Peek(xOriginalURLHeader)
@ -181,3 +205,46 @@ func (c *AutheliaCtx) RemoteIP() net.IP {
return c.RequestCtx.RemoteIP() return c.RequestCtx.RemoteIP()
} }
// GetOriginalURL extract the URL from the request headers (X-Original-URI or X-Forwarded-* headers).
func (c *AutheliaCtx) GetOriginalURL() (*url.URL, error) {
originalURL := c.XOriginalURL()
if originalURL != nil {
parsedURL, err := url.ParseRequestURI(string(originalURL))
if err != nil {
return nil, fmt.Errorf("Unable to parse URL extracted from X-Original-URL header: %v", err)
}
c.Logger.Trace("Using X-Original-URL header content as targeted site URL")
return parsedURL, nil
}
forwardedProto := c.XForwardedProto()
forwardedHost := c.XForwardedHost()
forwardedURI := c.XForwardedURI()
if forwardedProto == nil {
return nil, errMissingXForwardedProto
}
if forwardedHost == nil {
return nil, errMissingXForwardedHost
}
var requestURI string
scheme := append(forwardedProto, protoHostSeparator...)
requestURI = string(append(scheme,
append(forwardedHost, forwardedURI...)...))
parsedURL, err := url.ParseRequestURI(requestURI)
if err != nil {
return nil, fmt.Errorf("Unable to parse URL %s: %v", requestURI, err)
}
c.Logger.Tracef("Using X-Fowarded-Proto, X-Forwarded-Host and X-Forwarded-URI headers " +
"to construct targeted site URL")
return parsedURL, nil
}

View File

@ -1,6 +1,7 @@
package middlewares_test package middlewares_test
import ( import (
"net/url"
"testing" "testing"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
@ -33,3 +34,39 @@ func TestShouldCallNextWithAutheliaCtx(t *testing.T) {
assert.True(t, nextCalled) assert.True(t, nextCalled)
} }
// Test getOriginalURL.
func TestShouldGetOriginalURLFromOriginalURLHeader(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
mock.Ctx.Request.Header.Set("X-Original-URL", "https://home.example.com")
originalURL, err := mock.Ctx.GetOriginalURL()
assert.NoError(t, err)
expectedURL, err := url.ParseRequestURI("https://home.example.com")
assert.NoError(t, err)
assert.Equal(t, expectedURL, originalURL)
}
func TestShouldGetOriginalURLFromForwardedHeadersWithoutURI(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https")
mock.Ctx.Request.Header.Set("X-Forwarded-Host", "home.example.com")
originalURL, err := mock.Ctx.GetOriginalURL()
assert.NoError(t, err)
expectedURL, err := url.ParseRequestURI("https://home.example.com")
assert.NoError(t, err)
assert.Equal(t, expectedURL, originalURL)
}
func TestShouldGetOriginalURLFromForwardedHeadersWithURI(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
mock.Ctx.Request.Header.Set("X-Original-URL", "htt-ps//home?-.example.com")
_, err := mock.Ctx.GetOriginalURL()
assert.Error(t, err)
assert.Equal(t, "Unable to parse URL extracted from X-Original-URL header: parse \"htt-ps//home?-.example.com\": invalid URI for request", err.Error())
}

View File

@ -16,3 +16,5 @@ var okMessageBytes = []byte("{\"status\":\"OK\"}")
const operationFailedMessage = "Operation failed" const operationFailedMessage = "Operation failed"
const identityVerificationTokenAlreadyUsedMessage = "The identity verification token has already been used" const identityVerificationTokenAlreadyUsedMessage = "The identity verification token has already been used"
const identityVerificationTokenHasExpiredMessage = "The identity verification token has expired" const identityVerificationTokenHasExpiredMessage = "The identity verification token has expired"
var protoHostSeparator = []byte("://")

View File

@ -0,0 +1,125 @@
package middlewares
import (
"io"
"net/http"
"net/url"
"github.com/valyala/fasthttp"
)
// AutheliaHandlerFunc is used with the NewHTTPToAutheliaHandlerAdaptor to encapsulate a func.
type AutheliaHandlerFunc func(ctx *AutheliaCtx, rw http.ResponseWriter, r *http.Request)
type netHTTPBody struct {
b []byte
}
// Read reads the body.
func (r *netHTTPBody) Read(p []byte) (int, error) {
if len(r.b) == 0 {
return 0, io.EOF
}
n := copy(p, r.b)
r.b = r.b[n:]
return n, nil
}
// Close closes the body.
func (r *netHTTPBody) Close() error {
r.b = r.b[:0]
return nil
}
type netHTTPResponseWriter struct {
statusCode int
h http.Header
body []byte
}
// StatusCode returns the status code.
func (w *netHTTPResponseWriter) StatusCode() int {
if w.statusCode == 0 {
return http.StatusOK
}
return w.statusCode
}
// Header returns the http.Header.
func (w *netHTTPResponseWriter) Header() http.Header {
if w.h == nil {
w.h = make(http.Header)
}
return w.h
}
// WriteHeader needs to be documented TODO: document it.
func (w *netHTTPResponseWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
}
// Write writes to the body.
func (w *netHTTPResponseWriter) Write(p []byte) (int, error) {
w.body = append(w.body, p...)
return len(p), nil
}
// NewHTTPToAutheliaHandlerAdaptor creates a new adaptor given the AutheliaHandlerFunc.
func NewHTTPToAutheliaHandlerAdaptor(h AutheliaHandlerFunc) RequestHandler {
return func(ctx *AutheliaCtx) {
var r http.Request
body := ctx.PostBody()
r.Method = string(ctx.Method())
r.Proto = "HTTP/1.1"
r.ProtoMajor = 1
r.ProtoMinor = 1
r.RequestURI = string(ctx.RequestURI())
r.ContentLength = int64(len(body))
r.Host = string(ctx.Host())
r.RemoteAddr = ctx.RemoteAddr().String()
hdr := make(http.Header)
ctx.Request.Header.VisitAll(func(k, v []byte) {
sk := string(k)
sv := string(v)
switch sk {
case "Transfer-Encoding":
r.TransferEncoding = append(r.TransferEncoding, sv)
default:
hdr.Set(sk, sv)
}
})
r.Header = hdr
r.Body = &netHTTPBody{body}
rURL, err := url.ParseRequestURI(r.RequestURI)
if err != nil {
ctx.Logger.Errorf("cannot parse requestURI %q: %s", r.RequestURI, err)
ctx.RequestCtx.Error("Internal Server Error", fasthttp.StatusInternalServerError)
return
}
r.URL = rURL
var w netHTTPResponseWriter
h(ctx, &w, r.WithContext(ctx))
ctx.SetStatusCode(w.StatusCode())
for k, vv := range w.Header() {
for _, v := range vv {
ctx.Response.Header.Set(k, v)
}
}
ctx.Write(w.body) //nolint:errcheck
}
}

View File

@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
jwt "github.com/dgrijalva/jwt-go" "github.com/dgrijalva/jwt-go"
"github.com/authelia/authelia/internal/templates" "github.com/authelia/authelia/internal/templates"
) )
@ -51,18 +51,13 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs) RequestHandle
return return
} }
if ctx.XForwardedProto() == nil { uri, err := ctx.ForwardedProtoHost()
ctx.Error(errMissingXForwardedProto, operationFailedMessage) if err != nil {
ctx.Error(err, operationFailedMessage)
return return
} }
if ctx.XForwardedHost() == nil { link := fmt.Sprintf("%s%s%s?token=%s", uri, ctx.Configuration.Server.Path, args.TargetEndpoint, ss)
ctx.Error(errMissingXForwardedHost, operationFailedMessage)
return
}
link := fmt.Sprintf("%s://%s%s%s?token=%s", ctx.XForwardedProto(),
ctx.XForwardedHost(), ctx.Configuration.Server.Path, args.TargetEndpoint, ss)
bufHTML := new(bytes.Buffer) bufHTML := new(bytes.Buffer)

View File

@ -1,7 +1,7 @@
package middlewares package middlewares
import ( import (
jwt "github.com/dgrijalva/jwt-go" "github.com/dgrijalva/jwt-go"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@ -9,6 +9,7 @@ import (
"github.com/authelia/authelia/internal/authorization" "github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/configuration/schema" "github.com/authelia/authelia/internal/configuration/schema"
"github.com/authelia/authelia/internal/notification" "github.com/authelia/authelia/internal/notification"
"github.com/authelia/authelia/internal/oidc"
"github.com/authelia/authelia/internal/regulation" "github.com/authelia/authelia/internal/regulation"
"github.com/authelia/authelia/internal/session" "github.com/authelia/authelia/internal/session"
"github.com/authelia/authelia/internal/storage" "github.com/authelia/authelia/internal/storage"
@ -31,6 +32,7 @@ type Providers struct {
Authorizer *authorization.Authorizer Authorizer *authorization.Authorizer
SessionProvider *session.Provider SessionProvider *session.Provider
Regulator *regulation.Regulator Regulator *regulation.Regulator
OpenIDConnect oidc.OpenIDConnectProvider
UserProvider authentication.UserProvider UserProvider authentication.UserProvider
StorageProvider storage.Provider StorageProvider storage.Provider
@ -43,6 +45,9 @@ type RequestHandler = func(*AutheliaCtx)
// Middleware represent an Authelia middleware. // Middleware represent an Authelia middleware.
type Middleware = func(RequestHandler) RequestHandler type Middleware = func(RequestHandler) RequestHandler
// RequestHandlerBridge bridge a AutheliaCtx handle to a RequestHandler handler.
type RequestHandlerBridge = func(RequestHandler) fasthttp.RequestHandler
// IdentityVerificationStartArgs represent the arguments used to customize the starting phase // IdentityVerificationStartArgs represent the arguments used to customize the starting phase
// of the identity verification process. // of the identity verification process.
type IdentityVerificationStartArgs struct { type IdentityVerificationStartArgs struct {

View File

@ -0,0 +1,75 @@
package oidc
import (
"github.com/ory/fosite"
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/authorization"
)
// 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:"-"`
}
// IsAuthenticationLevelSufficient returns if the provided authentication.Level is sufficient for the client of the AutheliaClient.
func (c InternalClient) IsAuthenticationLevelSufficient(level authentication.Level) bool {
return authorization.IsAuthLevelSufficient(level, c.Policy)
}
// GetID returns the ID.
func (c InternalClient) GetID() string {
return c.ID
}
// GetHashedSecret returns the Secret.
func (c InternalClient) GetHashedSecret() []byte {
return c.Secret
}
// GetRedirectURIs returns the RedirectURIs.
func (c InternalClient) GetRedirectURIs() []string {
return c.RedirectURIs
}
// GetGrantTypes returns the GrantTypes.
func (c InternalClient) GetGrantTypes() fosite.Arguments {
if len(c.GrantTypes) == 0 {
return fosite.Arguments{"authorization_code"}
}
return c.GrantTypes
}
// GetResponseTypes returns the ResponseTypes.
func (c InternalClient) GetResponseTypes() fosite.Arguments {
if len(c.ResponseTypes) == 0 {
return fosite.Arguments{"code"}
}
return c.ResponseTypes
}
// GetScopes returns the Scopes.
func (c InternalClient) GetScopes() fosite.Arguments {
return c.Scopes
}
// IsPublic returns the value of the Public property.
func (c InternalClient) IsPublic() bool {
return c.Public
}
// GetAudience returns the Audience.
func (c InternalClient) GetAudience() fosite.Arguments {
return c.Audience
}

View File

@ -0,0 +1,34 @@
package oidc
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/authorization"
)
func TestIsAuthenticationLevelSufficient(t *testing.T) {
c := InternalClient{}
c.Policy = authorization.Bypass
assert.True(t, c.IsAuthenticationLevelSufficient(authentication.NotAuthenticated))
assert.True(t, c.IsAuthenticationLevelSufficient(authentication.OneFactor))
assert.True(t, c.IsAuthenticationLevelSufficient(authentication.TwoFactor))
c.Policy = authorization.OneFactor
assert.False(t, c.IsAuthenticationLevelSufficient(authentication.NotAuthenticated))
assert.True(t, c.IsAuthenticationLevelSufficient(authentication.OneFactor))
assert.True(t, c.IsAuthenticationLevelSufficient(authentication.TwoFactor))
c.Policy = authorization.TwoFactor
assert.False(t, c.IsAuthenticationLevelSufficient(authentication.NotAuthenticated))
assert.False(t, c.IsAuthenticationLevelSufficient(authentication.OneFactor))
assert.True(t, c.IsAuthenticationLevelSufficient(authentication.TwoFactor))
c.Policy = authorization.Denied
assert.False(t, c.IsAuthenticationLevelSufficient(authentication.NotAuthenticated))
assert.False(t, c.IsAuthenticationLevelSufficient(authentication.OneFactor))
assert.False(t, c.IsAuthenticationLevelSufficient(authentication.TwoFactor))
}

View File

@ -0,0 +1,5 @@
package oidc
import "errors"
var errPasswordsDoNotMatch = errors.New("the passwords don't match")

View File

@ -0,0 +1,24 @@
package oidc
import (
"context"
"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) {
if subtle.ConstantTimeCompare(hash, data) == 0 {
return errPasswordsDoNotMatch
}
return nil
}
// Hash creates a new hash from data.
func (h AutheliaHasher) Hash(ctx context.Context, data []byte) (hash []byte, err error) {
return data, nil
}

View File

@ -0,0 +1,47 @@
package oidc
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestShouldNotRaiseErrorOnEqualPasswordsPlainText(t *testing.T) {
hasher := AutheliaHasher{}
a := []byte("abc")
b := []byte("abc")
ctx := context.Background()
err := hasher.Compare(ctx, a, b)
assert.NoError(t, err)
}
func TestShouldRaiseErrorOnNonEqualPasswordsPlainText(t *testing.T) {
hasher := AutheliaHasher{}
a := []byte("abc")
b := []byte("abcd")
ctx := context.Background()
err := hasher.Compare(ctx, a, b)
assert.Equal(t, errPasswordsDoNotMatch, err)
}
func TestShouldHashPassword(t *testing.T) {
hasher := AutheliaHasher{}
data := []byte("abc")
ctx := context.Background()
hash, err := hasher.Hash(ctx, data)
assert.NoError(t, err)
assert.Equal(t, data, hash)
}

View File

@ -0,0 +1,108 @@
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{
Fosite: nil,
}
if configuration == nil {
return provider, nil
}
provider.Store = NewOpenIDConnectStore(configuration)
composeConfiguration := new(compose.Config)
key, err := utils.ParseRsaPrivateKeyFromPemStr(configuration.IssuerPrivateKey)
if err != nil {
return provider, fmt.Errorf("unable to parse the private key of the OpenID issuer: %w", err)
}
provider.privateKeys = make(map[string]*rsa.PrivateKey)
provider.privateKeys["main-key"] = key
// TODO: Consider implementing RS512 as well.
jwtStrategy := &jwt.RS256JWTStrategy{PrivateKey: key}
strategy := &compose.CommonStrategy{
CoreStrategy: compose.NewOAuth2HMACStrategy(
composeConfiguration,
[]byte(utils.HashSHA256FromString(configuration.HMACSecret)),
nil,
),
OpenIDConnectTokenStrategy: compose.NewOpenIDConnectStrategy(
composeConfiguration,
provider.privateKeys["main-key"],
),
JWTStrategy: jwtStrategy,
}
provider.Fosite = compose.Compose(
composeConfiguration,
provider.Store,
strategy,
AutheliaHasher{},
/*
These are the OAuth2 and OpenIDConnect factories. Order is important (the OAuth2 factories at the top must
be before the OpenIDConnect factories) and taken directly from fosite.compose.ComposeAllEnabled. The
commented factories are not enabled as we don't yet use them but are still here for reference purposes.
*/
compose.OAuth2AuthorizeExplicitFactory,
compose.OAuth2AuthorizeImplicitFactory,
compose.OAuth2ClientCredentialsGrantFactory,
compose.OAuth2RefreshTokenGrantFactory,
compose.OAuth2ResourceOwnerPasswordCredentialsFactory,
// compose.RFC7523AssertionGrantFactory,
compose.OpenIDConnectExplicitFactory,
compose.OpenIDConnectImplicitFactory,
compose.OpenIDConnectHybridFactory,
compose.OpenIDConnectRefreshFactory,
compose.OAuth2TokenIntrospectionFactory,
compose.OAuth2TokenRevocationFactory,
// compose.OAuth2PKCEFactory,
)
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

@ -0,0 +1,40 @@
package oidc
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/authelia/authelia/internal/configuration/schema"
)
var exampleIssuerPrivateKey = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAvcMVMB2vEbqI6PlSNJ4HmUyMxBDJ5iY7FS+zDDAHOZBg9S3S\nKcAn1CZcnyL0VvJ7wcdhR6oTnOwR94eKvzUyJZ+GL2hTMm27dubEYsNdhoCl6N3X\nyEEohNfoxiiCYraVauX8X3M9jFzbEz9+pacaDbHB2syaJ1qFmMNR+HSu2jPzOo7M\nlqKIOgUzA0741MaYNt47AEVg4XU5ORLdolbAkItmYg1QbyFndg9H5IvwKkYaXTGE\nlgDBcPUC0yVjAC15Mguquq+jZeQay+6PSbHTD8PQMOkLjyChI2xEhVNbdCXe676R\ncMW2R/gjrcK23zmtmTWRfdC1iZLSlHO+bJj9vQIDAQABAoIBAEZvkP/JJOCJwqPn\nV3IcbmmilmV4bdi1vByDFgyiDyx4wOSA24+PubjvfFW9XcCgRPuKjDtTj/AhWBHv\nB7stfa2lZuNV7/u562mZArA+IAr62Zp0LdIxDV8x3T8gbjVB3HhPYbv0RJZDKTYd\nzV6jhfIrVu9mHpoY6ZnodhapCPYIyk/d49KBIHZuAc25CUjMXgTeaVtf0c996036\nUxW6ef33wAOJAvW0RCvbXAJfmBeEq2qQlkjTIlpYx71fhZWexHifi8Ouv3Zonc+1\n/P2Adq5uzYVBT92f9RKHg9QxxNzVrLjSMaxyvUtWQCAQfW0tFIRdqBGsHYsQrFtI\nF4yzv8ECgYEA7ntpyN9HD9Z9lYQzPCR73sFCLM+ID99aVij0wHuxK97bkSyyvkLd\n7MyTaym3lg1UEqWNWBCLvFULZx7F0Ah6qCzD4ymm3Bj/ADpWWPgljBI0AFml+HHs\nhcATmXUrj5QbLyhiP2gmJjajp1o/rgATx6ED66seSynD6JOH8wUhhZUCgYEAy7OA\n06PF8GfseNsTqlDjNF0K7lOqd21S0prdwrsJLiVzUlfMM25MLE0XLDUutCnRheeh\nIlcuDoBsVTxz6rkvFGD74N+pgXlN4CicsBq5ofK060PbqCQhSII3fmHobrZ9Cr75\nHmBjAxHx998SKaAAGbBbcYGUAp521i1pH5CEPYkCgYEAkUd1Zf0+2RMdZhwm6hh/\nrW+l1I6IoMK70YkZsLipccRNld7Y9LbfYwYtODcts6di9AkOVfueZJiaXbONZfIE\nZrb+jkAteh9wGL9xIrnohbABJcV3Kiaco84jInUSmGDtPokncOENfHIEuEpuSJ2b\nbx1TuhmAVuGWivR0+ULC7RECgYEAgS0cDRpWc9Xzh9Cl7+PLsXEvdWNpPsL9OsEq\n0Ep7z9+/+f/jZtoTRCS/BTHUpDvAuwHglT5j3p5iFMt5VuiIiovWLwynGYwrbnNS\nqfrIrYKUaH1n1oDS+oBZYLQGCe9/7EifAjxtjYzbvSyg//SPG7tSwfBCREbpZXj2\nqSWkNsECgYA/mCDzCTlrrWPuiepo6kTmN+4TnFA+hJI6NccDVQ+jvbqEdoJ4SW4L\nzqfZSZRFJMNpSgIqkQNRPJqMP0jQ5KRtJrjMWBnYxktwKz9fDg2R2MxdFgMF2LH2\nHEMMhFHlv8NDjVOXh1KwRoltNGVWYsSrD9wKU9GhRCEfmNCGrvBcEg==\n-----END RSA PRIVATE KEY-----"
func TestOpenIDConnectProvider_NewOpenIDConnectProvider_NotConfigured(t *testing.T) {
provider, err := NewOpenIDConnectProvider(nil)
assert.NoError(t, err)
assert.Nil(t, provider.Fosite)
assert.Nil(t, provider.Store)
}
func TestOpenIDConnectProvider_NewOpenIDConnectProvider_BadIssuerKey(t *testing.T) {
_, err := NewOpenIDConnectProvider(&schema.OpenIDConnectConfiguration{
IssuerPrivateKey: "BAD KEY",
})
assert.Error(t, err, "abc")
}
func TestOpenIDConnectProvider_GetKeySet(t *testing.T) {
p, err := NewOpenIDConnectProvider(&schema.OpenIDConnectConfiguration{
IssuerPrivateKey: exampleIssuerPrivateKey,
})
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

@ -0,0 +1,219 @@
package oidc
import (
"context"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/storage"
"gopkg.in/square/go-jose.v2"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/configuration/schema"
"github.com/authelia/authelia/internal/logging"
)
// 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),
Users: map[string]storage.MemoryUserRelation{},
AuthorizeCodes: map[string]storage.StoreAuthorizeCode{},
AccessTokens: map[string]fosite.Requester{},
RefreshTokens: map[string]storage.StoreRefreshToken{},
PKCES: map[string]fosite.Requester{},
AccessTokenRequestIDs: map[string]string{},
RefreshTokenRequestIDs: map[string]string{},
}
return store
}
// OpenIDConnectStore is Authelia's internal representation of the fosite.Storage interface.
//
// Currently it is mostly just implementing a decorator pattern other then GetInternalClient.
// The long term plan is to have these methods interact with the Authelia storage and
// session providers where applicable.
type OpenIDConnectStore struct {
clients map[string]*InternalClient
memory *storage.MemoryStore
}
// GetClientPolicy retrieves the policy from the client with the matching provided id.
func (s OpenIDConnectStore) GetClientPolicy(id string) (level authorization.Level) {
client, err := s.GetInternalClient(id)
if err != nil {
return authorization.TwoFactor
}
return client.Policy
}
// GetInternalClient returns a fosite.Client asserted as an InternalClient matching the provided id.
func (s OpenIDConnectStore) GetInternalClient(id string) (client *InternalClient, err error) {
client, ok := s.clients[id]
if !ok {
return nil, fosite.ErrNotFound
}
return client, nil
}
// IsValidClientID returns true if the provided id exists in the OpenIDConnectProvider.Clients map.
func (s OpenIDConnectStore) IsValidClientID(id string) (valid bool) {
_, err := s.GetInternalClient(id)
return err == nil
}
// CreateOpenIDConnectSession decorates fosite's storage.MemoryStore CreateOpenIDConnectSession method.
func (s *OpenIDConnectStore) CreateOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) error {
return s.memory.CreateOpenIDConnectSession(ctx, authorizeCode, requester)
}
// GetOpenIDConnectSession decorates fosite's storage.MemoryStore GetOpenIDConnectSession method.
func (s *OpenIDConnectStore) GetOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) (fosite.Requester, error) {
return s.memory.GetOpenIDConnectSession(ctx, authorizeCode, requester)
}
// DeleteOpenIDConnectSession decorates fosite's storage.MemoryStore DeleteOpenIDConnectSession method.
func (s *OpenIDConnectStore) DeleteOpenIDConnectSession(ctx context.Context, authorizeCode string) error {
return s.memory.DeleteOpenIDConnectSession(ctx, authorizeCode)
}
// GetClient decorates fosite's storage.MemoryStore GetClient method.
func (s *OpenIDConnectStore) GetClient(_ context.Context, id string) (fosite.Client, error) {
return s.GetInternalClient(id)
}
// ClientAssertionJWTValid decorates fosite's storage.MemoryStore ClientAssertionJWTValid method.
func (s *OpenIDConnectStore) ClientAssertionJWTValid(ctx context.Context, jti string) error {
return s.memory.ClientAssertionJWTValid(ctx, jti)
}
// SetClientAssertionJWT decorates fosite's storage.MemoryStore SetClientAssertionJWT method.
func (s *OpenIDConnectStore) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error {
return s.memory.SetClientAssertionJWT(ctx, jti, exp)
}
// CreateAuthorizeCodeSession decorates fosite's storage.MemoryStore CreateAuthorizeCodeSession method.
func (s *OpenIDConnectStore) CreateAuthorizeCodeSession(ctx context.Context, code string, req fosite.Requester) error {
return s.memory.CreateAuthorizeCodeSession(ctx, code, req)
}
// GetAuthorizeCodeSession decorates fosite's storage.MemoryStore GetAuthorizeCodeSession method.
func (s *OpenIDConnectStore) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (fosite.Requester, error) {
return s.memory.GetAuthorizeCodeSession(ctx, code, session)
}
// InvalidateAuthorizeCodeSession decorates fosite's storage.MemoryStore InvalidateAuthorizeCodeSession method.
func (s *OpenIDConnectStore) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error {
return s.memory.InvalidateAuthorizeCodeSession(ctx, code)
}
// CreatePKCERequestSession decorates fosite's storage.MemoryStore CreatePKCERequestSession method.
func (s *OpenIDConnectStore) CreatePKCERequestSession(ctx context.Context, code string, req fosite.Requester) error {
return s.memory.CreatePKCERequestSession(ctx, code, req)
}
// GetPKCERequestSession decorates fosite's storage.MemoryStore GetPKCERequestSession method.
func (s *OpenIDConnectStore) GetPKCERequestSession(ctx context.Context, code string, session fosite.Session) (fosite.Requester, error) {
return s.memory.GetPKCERequestSession(ctx, code, session)
}
// DeletePKCERequestSession decorates fosite's storage.MemoryStore DeletePKCERequestSession method.
func (s *OpenIDConnectStore) DeletePKCERequestSession(ctx context.Context, code string) error {
return s.memory.DeletePKCERequestSession(ctx, code)
}
// CreateAccessTokenSession decorates fosite's storage.MemoryStore CreateAccessTokenSession method.
func (s *OpenIDConnectStore) CreateAccessTokenSession(ctx context.Context, signature string, req fosite.Requester) error {
return s.memory.CreateAccessTokenSession(ctx, signature, req)
}
// GetAccessTokenSession decorates fosite's storage.MemoryStore GetAccessTokenSession method.
func (s *OpenIDConnectStore) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) {
return s.memory.GetAccessTokenSession(ctx, signature, session)
}
// DeleteAccessTokenSession decorates fosite's storage.MemoryStore DeleteAccessTokenSession method.
func (s *OpenIDConnectStore) DeleteAccessTokenSession(ctx context.Context, signature string) error {
return s.memory.DeleteAccessTokenSession(ctx, signature)
}
// CreateRefreshTokenSession decorates fosite's storage.MemoryStore CreateRefreshTokenSession method.
func (s *OpenIDConnectStore) CreateRefreshTokenSession(ctx context.Context, signature string, req fosite.Requester) error {
return s.memory.CreateRefreshTokenSession(ctx, signature, req)
}
// GetRefreshTokenSession decorates fosite's storage.MemoryStore GetRefreshTokenSession method.
func (s *OpenIDConnectStore) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) {
return s.memory.GetRefreshTokenSession(ctx, signature, session)
}
// DeleteRefreshTokenSession decorates fosite's storage.MemoryStore DeleteRefreshTokenSession method.
func (s *OpenIDConnectStore) DeleteRefreshTokenSession(ctx context.Context, signature string) error {
return s.memory.DeleteRefreshTokenSession(ctx, signature)
}
// Authenticate decorates fosite's storage.MemoryStore Authenticate method.
func (s *OpenIDConnectStore) Authenticate(ctx context.Context, name string, secret string) error {
return s.memory.Authenticate(ctx, name, secret)
}
// RevokeRefreshToken decorates fosite's storage.MemoryStore RevokeRefreshToken method.
func (s *OpenIDConnectStore) RevokeRefreshToken(ctx context.Context, requestID string) error {
return s.memory.RevokeRefreshToken(ctx, requestID)
}
// RevokeAccessToken decorates fosite's storage.MemoryStore RevokeAccessToken method.
func (s *OpenIDConnectStore) RevokeAccessToken(ctx context.Context, requestID string) error {
return s.memory.RevokeAccessToken(ctx, requestID)
}
// GetPublicKey decorates fosite's storage.MemoryStore GetPublicKey method.
func (s *OpenIDConnectStore) GetPublicKey(ctx context.Context, issuer string, subject string, keyID string) (*jose.JSONWebKey, error) {
return s.memory.GetPublicKey(ctx, issuer, subject, keyID)
}
// GetPublicKeys decorates fosite's storage.MemoryStore GetPublicKeys method.
func (s *OpenIDConnectStore) GetPublicKeys(ctx context.Context, issuer string, subject string) (*jose.JSONWebKeySet, error) {
return s.memory.GetPublicKeys(ctx, issuer, subject)
}
// GetPublicKeyScopes decorates fosite's storage.MemoryStore GetPublicKeyScopes method.
func (s *OpenIDConnectStore) GetPublicKeyScopes(ctx context.Context, issuer string, subject string, keyID string) ([]string, error) {
return s.memory.GetPublicKeyScopes(ctx, issuer, subject, keyID)
}
// IsJWTUsed decorates fosite's storage.MemoryStore IsJWTUsed method.
func (s *OpenIDConnectStore) IsJWTUsed(ctx context.Context, jti string) (bool, error) {
return s.memory.IsJWTUsed(ctx, jti)
}
// MarkJWTUsedForTime decorates fosite's storage.MemoryStore MarkJWTUsedForTime method.
func (s *OpenIDConnectStore) MarkJWTUsedForTime(ctx context.Context, jti string, exp time.Time) error {
return s.memory.MarkJWTUsedForTime(ctx, jti, exp)
}

View File

@ -0,0 +1,132 @@
package oidc
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/configuration/schema"
)
func TestOpenIDConnectStore_GetClientPolicy(t *testing.T) {
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
IssuerPrivateKey: exampleIssuerPrivateKey,
Clients: []schema.OpenIDConnectClientConfiguration{
{
ID: "myclient",
Description: "myclient desc",
Policy: "one_factor",
Scopes: []string{"openid", "profile"},
Secret: "mysecret",
},
{
ID: "myotherclient",
Description: "myclient desc",
Policy: "two_factor",
Scopes: []string{"openid", "profile"},
Secret: "mysecret",
},
},
})
policyOne := s.GetClientPolicy("myclient")
assert.Equal(t, authorization.OneFactor, policyOne)
policyTwo := s.GetClientPolicy("myotherclient")
assert.Equal(t, authorization.TwoFactor, policyTwo)
policyInvalid := s.GetClientPolicy("invalidclient")
assert.Equal(t, authorization.TwoFactor, policyInvalid)
}
func TestOpenIDConnectStore_GetInternalClient(t *testing.T) {
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
IssuerPrivateKey: exampleIssuerPrivateKey,
Clients: []schema.OpenIDConnectClientConfiguration{
{
ID: "myclient",
Description: "myclient desc",
Policy: "one_factor",
Scopes: []string{"openid", "profile"},
Secret: "mysecret",
},
},
})
client, err := s.GetClient(context.Background(), "myinvalidclient")
assert.EqualError(t, err, "not_found")
assert.Nil(t, client)
client, err = s.GetClient(context.Background(), "myclient")
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, "myclient", client.GetID())
}
func TestOpenIDConnectStore_GetInternalClient_ValidClient(t *testing.T) {
c1 := schema.OpenIDConnectClientConfiguration{
ID: "myclient",
Description: "myclient desc",
Policy: "one_factor",
Scopes: []string{"openid", "profile"},
Secret: "mysecret",
}
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
IssuerPrivateKey: exampleIssuerPrivateKey,
Clients: []schema.OpenIDConnectClientConfiguration{c1},
})
client, err := s.GetInternalClient(c1.ID)
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, client.ID, c1.ID)
assert.Equal(t, client.Description, c1.Description)
assert.Equal(t, client.Scopes, c1.Scopes)
assert.Equal(t, client.GrantTypes, c1.GrantTypes)
assert.Equal(t, client.ResponseTypes, c1.ResponseTypes)
assert.Equal(t, client.RedirectURIs, c1.RedirectURIs)
assert.Equal(t, client.Policy, authorization.OneFactor)
assert.Equal(t, client.Secret, []byte(c1.Secret))
}
func TestOpenIDConnectStore_GetInternalClient_InvalidClient(t *testing.T) {
c1 := schema.OpenIDConnectClientConfiguration{
ID: "myclient",
Description: "myclient desc",
Policy: "one_factor",
Scopes: []string{"openid", "profile"},
Secret: "mysecret",
}
s := NewOpenIDConnectStore(&schema.OpenIDConnectConfiguration{
IssuerPrivateKey: exampleIssuerPrivateKey,
Clients: []schema.OpenIDConnectClientConfiguration{c1},
})
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{
IssuerPrivateKey: exampleIssuerPrivateKey,
Clients: []schema.OpenIDConnectClientConfiguration{
{
ID: "myclient",
Description: "myclient desc",
Policy: "one_factor",
Scopes: []string{"openid", "profile"},
Secret: "mysecret",
},
},
})
validClient := s.IsValidClientID("myclient")
invalidClient := s.IsValidClientID("myinvalidclient")
assert.True(t, validClient)
assert.False(t, invalidClient)
}

View File

@ -28,9 +28,7 @@ import (
//go:embed public_html //go:embed public_html
var assets embed.FS var assets embed.FS
// StartServer start Authelia server with the given configuration and providers. func registerRoutes(configuration schema.Configuration, providers middlewares.Providers) fasthttp.RequestHandler {
func StartServer(configuration schema.Configuration, providers middlewares.Providers) {
logger := logging.Logger()
autheliaMiddleware := middlewares.AutheliaMiddleware(configuration, providers) autheliaMiddleware := middlewares.AutheliaMiddleware(configuration, providers)
rememberMe := strconv.FormatBool(configuration.Session.RememberMeDuration != "0") rememberMe := strconv.FormatBool(configuration.Session.RememberMeDuration != "0")
resetPassword := strconv.FormatBool(!configuration.AuthenticationBackend.DisableResetPassword) resetPassword := strconv.FormatBool(!configuration.AuthenticationBackend.DisableResetPassword)
@ -142,6 +140,19 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
handler = middlewares.StripPathMiddleware(handler) handler = middlewares.StripPathMiddleware(handler)
} }
if providers.OpenIDConnect.Fosite != nil {
handlers.RegisterOIDC(r, autheliaMiddleware)
}
return handler
}
// StartServer start Authelia server with the given configuration and providers.
func StartServer(configuration schema.Configuration, providers middlewares.Providers) {
logger := logging.Logger()
handler := registerRoutes(configuration, providers)
server := &fasthttp.Server{ server := &fasthttp.Server{
ErrorHandler: autheliaErrorHandler, ErrorHandler: autheliaErrorHandler,
Handler: handler, Handler: handler,
@ -157,6 +168,7 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
logger.Fatalf("Error initializing listener: %s", err) logger.Fatalf("Error initializing listener: %s", err)
} }
// TODO(clems4ever): move that piece to a more related location, probably in the configuration package.
if configuration.AuthenticationBackend.File != nil && configuration.AuthenticationBackend.File.Password.Algorithm == "argon2id" && runtime.GOOS == "linux" { if configuration.AuthenticationBackend.File != nil && configuration.AuthenticationBackend.File.Password.Algorithm == "argon2id" && runtime.GOOS == "linux" {
f, err := ioutil.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes") f, err := ioutil.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes")
if err != nil { if err != nil {

View File

@ -87,6 +87,7 @@ func (p *Provider) GetSession(ctx *fasthttp.RequestCtx) (UserSession, error) {
// and save it in the store. // and save it in the store.
if !ok { if !ok {
userSession := NewDefaultUserSession() userSession := NewDefaultUserSession()
store.Set(userSessionStorerKey, userSession) store.Set(userSessionStorerKey, userSession)
return userSession, nil return userSession, nil
@ -130,6 +131,7 @@ func (p *Provider) SaveSession(ctx *fasthttp.RequestCtx, userSession UserSession
// RegenerateSession regenerate a session ID. // RegenerateSession regenerate a session ID.
func (p *Provider) RegenerateSession(ctx *fasthttp.RequestCtx) error { func (p *Provider) RegenerateSession(ctx *fasthttp.RequestCtx) error {
err := p.sessionHolder.Regenerate(ctx) err := p.sessionHolder.Regenerate(ctx)
return err return err
} }

View File

@ -8,6 +8,7 @@ import (
"github.com/tstranex/u2f" "github.com/tstranex/u2f"
"github.com/authelia/authelia/internal/authentication" "github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/authorization"
) )
// ProviderConfig is the configuration used to create the session provider. // ProviderConfig is the configuration used to create the session provider.
@ -43,6 +44,9 @@ type UserSession struct {
// This is used in second phase of a U2F authentication. // This is used in second phase of a U2F authentication.
U2FRegistration *U2FRegistration U2FRegistration *U2FRegistration
// Represent an OIDC workflow session initiated by the client if not null.
OIDCWorkflowSession *OIDCWorkflowSession
// This boolean is set to true after identity verification and checked // This boolean is set to true after identity verification and checked
// while doing the query actually updating the password. // while doing the query actually updating the password.
PasswordResetUsername *string PasswordResetUsername *string
@ -55,3 +59,15 @@ type Identity struct {
Username string Username string
Email string Email string
} }
// OIDCWorkflowSession represent an OIDC workflow session.
type OIDCWorkflowSession struct {
ClientID string
RequestedScopes []string
GrantedScopes []string
RequestedAudience []string
GrantedAudience []string
TargetURI string
AuthURI string
RequiredAuthorizationLevel authorization.Level
}

View File

@ -0,0 +1,99 @@
---
port: 9091
tls_cert: /config/ssl/cert.pem
tls_key: /config/ssl/key.pem
log_level: debug
jwt_secret: unsecure_secret
authentication_backend:
file:
path: /config/users.yml
session:
secret: unsecure_session_secret
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
# We use redis here to keep the users authenticated when Authelia restarts
# It eases development.
redis:
host: redis
port: 6379
storage:
local:
path: /config/db.sqlite
access_control:
default_policy: deny
rules:
- domain: "home.example.com"
policy: bypass
- domain: "public.example.com"
policy: bypass
- domain: "admin.example.com"
policy: two_factor
- domain: "secure.example.com"
policy: two_factor
- domain: "singlefactor.example.com"
policy: one_factor
- domain: "oidc.example.com"
policy: two_factor
- domain: "oidc-public.example.com"
policy: bypass
notifier:
smtp:
host: smtp
port: 1025
sender: admin@example.com
disable_require_tls: true
identity_providers:
oidc:
hmac_secret: IVPWBkAdJHje3uz7LtFTDU2pFUfh39Xm
issuer_private_key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAvOFmoEJFt1JkfdlwM3vJFg5rrY9d6LyyqezjZkBZDQ4qdEEU
dCrbW8ISFTtg9sfbrS3qingUzVP9VOfYPMC3r0ugjJXjhvJdBSaoLlzL3saeyrXk
frOOvkcWKzeOynqUNPhKy9dchmuLALFfd/Jy7Wzq0y7XxGeNidEmFjMAf9dwf6/+
PjQjbG7zBFu/XSajITPHlDXPVDd0j2qw2wu5Z9iqn4LRXnAFnC438hZZKZU/+JxU
2ezr6Sefiy8XTC2kDiq3cgLeEjSywlJOs+4TLjVS/3h75sh2Wk0xVaSwjPEjCOgm
a+2E3GJrGdQBiAjMSu101VBVwHUHaLDCn1T4NwIDAQABAoIBADWkupXnXI99Ogc4
GxK0JF88Rz6qyhwQg5mZKthejCwWCt6roRiBF33O933KOHa+OljMAqHDCv1pzjgw
BIz0mvaRPw7OfylTajHNUdShDFHADVc7I6MMcgz+eYBarhY5jCAjKHMOPjv7DSZs
OdYCKLvfxC2oTyV714n9uZhyccDcvQpkgZuBDL0oxPom1GOI8TGhPjxvFOovEHWA
Q8q9XY4cUVNDikZmvpgeUkJHWYHYb+11vKeSupnYD03yJ3sDy+F6+m+3/XmzFbXb
1p43ermHQsMfDlxPyulUUI0viSo2UhlMC/moAb9FusOv+dTl2lt0gGqzDJ9gg1z1
XpHRnwkCgYEA5x48dyxd4lydtVYef9sBmbLJEYozsYyOwLcnrLSNaZxeCza1exyR
QIRogswoLDacxrYvO8FY6LtAEMkisv732M29zthBPm5wyoSZiM1X2YfQXKsmyh2h
x1/yCWv/BQjj68A8IAxToaXxSG4WAr/X00RGUkXgkgw122FxcmGuFyUCgYEA0TcR
dnt/oRMK4aCZHcBgTknzDfxKlJh4S0C9WjxKgr8IlW4LTeVSBuuqOObOQYImEhtw
TRTKZIViL0roDF79cioQSp1Tk5h6uy8wr6VyhWRnWfTz2/azoTHnmQ780rtAuEI/
NvE6FiqwikJLjma1YJoRfr/bfmgMdxcYbJI1MSsCgYAEZ5Yda1IKu1siFpcUNrdM
F5UvaWPc0WHzGEqARxye06UTL6K7yuqVwTBAteVaGlxYiSZTTDcGkHMDHuIzaRqO
HjWs2IA90VsC8Q4ABnHTKnx1F6nwlin8I774IP/GN8ooNwyuS63YWdJEYBy5RrC1
TQrODJjgD62DFdNUq7nmpQKBgFMJEzI+Q+KPJ0NztTG8t7x61y/W0Vb2yM+9Syn0
QfJwlZyRR4VMHelHQZFB8dzIJgoLv9+n/8gztEtm5IB8dwUHst2aYaBz5UpDqYQd
Gz3cIrTuZpcH7DVvFCeIbknJLh+zk1lgFpjTqqvFMi27kANeQtFWnmwmKcRec0As
K1ZvAoGAV/3YB44/zIoB590+yhpx2HTmDPVHH+J+5O71Pi1D9W13ClBFLrE69wo+
IQLIstBI5tGOGeuQNjXhDKJ1U30xppZXcnebrkA+oOo+6dy20zghFR2maAGXfWFU
pM4GsSnSTm0bXPebVouQFqhj7LqcQQzCqRDThmw/Lp1tJUmu40g=
-----END RSA PRIVATE KEY-----
clients:
- id: oidc-tester-app
secret: foobar
policy: two_factor
redirect_uris:
- https://oidc.example.com:8080/oauth2/callback
# This client is used for testing purpose. As of now, the app must be protected by ACLs
# otherwise it won't work properly.
- id: oidc-tester-app-public
secret: foobar
authorization_policy: one_factor
redirect_uris:
- https://oidc-public.example.com:8080/oauth2/callback
...

View File

@ -0,0 +1,10 @@
---
version: '3'
services:
authelia-backend:
volumes:
- './OIDC/configuration.yml:/config/configuration.yml:ro'
- './OIDC/users.yml:/config/users.yml'
- './OIDC/keypair/key.pem:/config/issuer.pem:ro'
- './common/ssl:/config/ssl:ro'
...

View File

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAvOFmoEJFt1JkfdlwM3vJFg5rrY9d6LyyqezjZkBZDQ4qdEEU
dCrbW8ISFTtg9sfbrS3qingUzVP9VOfYPMC3r0ugjJXjhvJdBSaoLlzL3saeyrXk
frOOvkcWKzeOynqUNPhKy9dchmuLALFfd/Jy7Wzq0y7XxGeNidEmFjMAf9dwf6/+
PjQjbG7zBFu/XSajITPHlDXPVDd0j2qw2wu5Z9iqn4LRXnAFnC438hZZKZU/+JxU
2ezr6Sefiy8XTC2kDiq3cgLeEjSywlJOs+4TLjVS/3h75sh2Wk0xVaSwjPEjCOgm
a+2E3GJrGdQBiAjMSu101VBVwHUHaLDCn1T4NwIDAQABAoIBADWkupXnXI99Ogc4
GxK0JF88Rz6qyhwQg5mZKthejCwWCt6roRiBF33O933KOHa+OljMAqHDCv1pzjgw
BIz0mvaRPw7OfylTajHNUdShDFHADVc7I6MMcgz+eYBarhY5jCAjKHMOPjv7DSZs
OdYCKLvfxC2oTyV714n9uZhyccDcvQpkgZuBDL0oxPom1GOI8TGhPjxvFOovEHWA
Q8q9XY4cUVNDikZmvpgeUkJHWYHYb+11vKeSupnYD03yJ3sDy+F6+m+3/XmzFbXb
1p43ermHQsMfDlxPyulUUI0viSo2UhlMC/moAb9FusOv+dTl2lt0gGqzDJ9gg1z1
XpHRnwkCgYEA5x48dyxd4lydtVYef9sBmbLJEYozsYyOwLcnrLSNaZxeCza1exyR
QIRogswoLDacxrYvO8FY6LtAEMkisv732M29zthBPm5wyoSZiM1X2YfQXKsmyh2h
x1/yCWv/BQjj68A8IAxToaXxSG4WAr/X00RGUkXgkgw122FxcmGuFyUCgYEA0TcR
dnt/oRMK4aCZHcBgTknzDfxKlJh4S0C9WjxKgr8IlW4LTeVSBuuqOObOQYImEhtw
TRTKZIViL0roDF79cioQSp1Tk5h6uy8wr6VyhWRnWfTz2/azoTHnmQ780rtAuEI/
NvE6FiqwikJLjma1YJoRfr/bfmgMdxcYbJI1MSsCgYAEZ5Yda1IKu1siFpcUNrdM
F5UvaWPc0WHzGEqARxye06UTL6K7yuqVwTBAteVaGlxYiSZTTDcGkHMDHuIzaRqO
HjWs2IA90VsC8Q4ABnHTKnx1F6nwlin8I774IP/GN8ooNwyuS63YWdJEYBy5RrC1
TQrODJjgD62DFdNUq7nmpQKBgFMJEzI+Q+KPJ0NztTG8t7x61y/W0Vb2yM+9Syn0
QfJwlZyRR4VMHelHQZFB8dzIJgoLv9+n/8gztEtm5IB8dwUHst2aYaBz5UpDqYQd
Gz3cIrTuZpcH7DVvFCeIbknJLh+zk1lgFpjTqqvFMi27kANeQtFWnmwmKcRec0As
K1ZvAoGAV/3YB44/zIoB590+yhpx2HTmDPVHH+J+5O71Pi1D9W13ClBFLrE69wo+
IQLIstBI5tGOGeuQNjXhDKJ1U30xppZXcnebrkA+oOo+6dy20zghFR2maAGXfWFU
pM4GsSnSTm0bXPebVouQFqhj7LqcQQzCqRDThmw/Lp1tJUmu40g=
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,9 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvOFmoEJFt1JkfdlwM3vJ
Fg5rrY9d6LyyqezjZkBZDQ4qdEEUdCrbW8ISFTtg9sfbrS3qingUzVP9VOfYPMC3
r0ugjJXjhvJdBSaoLlzL3saeyrXkfrOOvkcWKzeOynqUNPhKy9dchmuLALFfd/Jy
7Wzq0y7XxGeNidEmFjMAf9dwf6/+PjQjbG7zBFu/XSajITPHlDXPVDd0j2qw2wu5
Z9iqn4LRXnAFnC438hZZKZU/+JxU2ezr6Sefiy8XTC2kDiq3cgLeEjSywlJOs+4T
LjVS/3h75sh2Wk0xVaSwjPEjCOgma+2E3GJrGdQBiAjMSu101VBVwHUHaLDCn1T4
NwIDAQAB
-----END RSA PUBLIC KEY-----

View File

@ -0,0 +1,35 @@
---
###############################################################
# Users Database #
###############################################################
# This file can be used if you do not have an LDAP set up.
# List of users
users:
john:
displayname: "John Doe"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: john.doe@authelia.com
groups:
- admins
- dev
harry:
displayname: "Harry Potter"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: harry.potter@authelia.com
groups: []
bob:
displayname: "Bob Dylan"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: bob.dylan@authelia.com
groups:
- dev
james:
displayname: "James Dean"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: james.dean@authelia.com
...

View File

@ -0,0 +1,101 @@
---
port: 9091
tls_cert: /config/ssl/cert.pem
tls_key: /config/ssl/key.pem
log_level: debug
jwt_secret: unsecure_secret
authentication_backend:
file:
path: /config/users.yml
session:
secret: unsecure_session_secret
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
# We use redis here to keep the users authenticated when Authelia restarts
# It eases development.
redis:
host: redis
port: 6379
storage:
local:
path: /config/db.sqlite
access_control:
default_policy: deny
rules:
- domain: "home.example.com"
policy: bypass
- domain: "public.example.com"
policy: bypass
- domain: "admin.example.com"
policy: two_factor
- domain: "secure.example.com"
policy: two_factor
- domain: "singlefactor.example.com"
policy: one_factor
- domain: "oidc.example.com"
policy: two_factor
- domain: "oidc-public.example.com"
policy: bypass
- domain: "traefik.example.com"
policy: bypass
notifier:
smtp:
host: smtp
port: 1025
sender: admin@example.com
disable_require_tls: true
identity_providers:
oidc:
hmac_secret: IVPWBkAdJHje3uz7LtFTDU2pFUfh39Xm
issuer_private_key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAvOFmoEJFt1JkfdlwM3vJFg5rrY9d6LyyqezjZkBZDQ4qdEEU
dCrbW8ISFTtg9sfbrS3qingUzVP9VOfYPMC3r0ugjJXjhvJdBSaoLlzL3saeyrXk
frOOvkcWKzeOynqUNPhKy9dchmuLALFfd/Jy7Wzq0y7XxGeNidEmFjMAf9dwf6/+
PjQjbG7zBFu/XSajITPHlDXPVDd0j2qw2wu5Z9iqn4LRXnAFnC438hZZKZU/+JxU
2ezr6Sefiy8XTC2kDiq3cgLeEjSywlJOs+4TLjVS/3h75sh2Wk0xVaSwjPEjCOgm
a+2E3GJrGdQBiAjMSu101VBVwHUHaLDCn1T4NwIDAQABAoIBADWkupXnXI99Ogc4
GxK0JF88Rz6qyhwQg5mZKthejCwWCt6roRiBF33O933KOHa+OljMAqHDCv1pzjgw
BIz0mvaRPw7OfylTajHNUdShDFHADVc7I6MMcgz+eYBarhY5jCAjKHMOPjv7DSZs
OdYCKLvfxC2oTyV714n9uZhyccDcvQpkgZuBDL0oxPom1GOI8TGhPjxvFOovEHWA
Q8q9XY4cUVNDikZmvpgeUkJHWYHYb+11vKeSupnYD03yJ3sDy+F6+m+3/XmzFbXb
1p43ermHQsMfDlxPyulUUI0viSo2UhlMC/moAb9FusOv+dTl2lt0gGqzDJ9gg1z1
XpHRnwkCgYEA5x48dyxd4lydtVYef9sBmbLJEYozsYyOwLcnrLSNaZxeCza1exyR
QIRogswoLDacxrYvO8FY6LtAEMkisv732M29zthBPm5wyoSZiM1X2YfQXKsmyh2h
x1/yCWv/BQjj68A8IAxToaXxSG4WAr/X00RGUkXgkgw122FxcmGuFyUCgYEA0TcR
dnt/oRMK4aCZHcBgTknzDfxKlJh4S0C9WjxKgr8IlW4LTeVSBuuqOObOQYImEhtw
TRTKZIViL0roDF79cioQSp1Tk5h6uy8wr6VyhWRnWfTz2/azoTHnmQ780rtAuEI/
NvE6FiqwikJLjma1YJoRfr/bfmgMdxcYbJI1MSsCgYAEZ5Yda1IKu1siFpcUNrdM
F5UvaWPc0WHzGEqARxye06UTL6K7yuqVwTBAteVaGlxYiSZTTDcGkHMDHuIzaRqO
HjWs2IA90VsC8Q4ABnHTKnx1F6nwlin8I774IP/GN8ooNwyuS63YWdJEYBy5RrC1
TQrODJjgD62DFdNUq7nmpQKBgFMJEzI+Q+KPJ0NztTG8t7x61y/W0Vb2yM+9Syn0
QfJwlZyRR4VMHelHQZFB8dzIJgoLv9+n/8gztEtm5IB8dwUHst2aYaBz5UpDqYQd
Gz3cIrTuZpcH7DVvFCeIbknJLh+zk1lgFpjTqqvFMi27kANeQtFWnmwmKcRec0As
K1ZvAoGAV/3YB44/zIoB590+yhpx2HTmDPVHH+J+5O71Pi1D9W13ClBFLrE69wo+
IQLIstBI5tGOGeuQNjXhDKJ1U30xppZXcnebrkA+oOo+6dy20zghFR2maAGXfWFU
pM4GsSnSTm0bXPebVouQFqhj7LqcQQzCqRDThmw/Lp1tJUmu40g=
-----END RSA PRIVATE KEY-----
clients:
- id: oidc-tester-app
secret: foobar
policy: two_factor
redirect_uris:
- https://oidc.example.com:8080/oauth2/callback
# This client is used for testing purpose. As of now, the app must be protected by ACLs
# otherwise it won't work properly.
- id: oidc-tester-app-public
secret: foobar
authorization_policy: one_factor
redirect_uris:
- https://oidc-public.example.com:8080/oauth2/callback
...

View File

@ -0,0 +1,10 @@
---
version: '3'
services:
authelia-backend:
volumes:
- './OIDCTraefik/configuration.yml:/config/configuration.yml:ro'
- './OIDCTraefik/users.yml:/config/users.yml'
- './OIDCTraefik/keypair/key.pem:/config/issuer.pem:ro'
- './common/ssl:/config/ssl:ro'
...

View File

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAvOFmoEJFt1JkfdlwM3vJFg5rrY9d6LyyqezjZkBZDQ4qdEEU
dCrbW8ISFTtg9sfbrS3qingUzVP9VOfYPMC3r0ugjJXjhvJdBSaoLlzL3saeyrXk
frOOvkcWKzeOynqUNPhKy9dchmuLALFfd/Jy7Wzq0y7XxGeNidEmFjMAf9dwf6/+
PjQjbG7zBFu/XSajITPHlDXPVDd0j2qw2wu5Z9iqn4LRXnAFnC438hZZKZU/+JxU
2ezr6Sefiy8XTC2kDiq3cgLeEjSywlJOs+4TLjVS/3h75sh2Wk0xVaSwjPEjCOgm
a+2E3GJrGdQBiAjMSu101VBVwHUHaLDCn1T4NwIDAQABAoIBADWkupXnXI99Ogc4
GxK0JF88Rz6qyhwQg5mZKthejCwWCt6roRiBF33O933KOHa+OljMAqHDCv1pzjgw
BIz0mvaRPw7OfylTajHNUdShDFHADVc7I6MMcgz+eYBarhY5jCAjKHMOPjv7DSZs
OdYCKLvfxC2oTyV714n9uZhyccDcvQpkgZuBDL0oxPom1GOI8TGhPjxvFOovEHWA
Q8q9XY4cUVNDikZmvpgeUkJHWYHYb+11vKeSupnYD03yJ3sDy+F6+m+3/XmzFbXb
1p43ermHQsMfDlxPyulUUI0viSo2UhlMC/moAb9FusOv+dTl2lt0gGqzDJ9gg1z1
XpHRnwkCgYEA5x48dyxd4lydtVYef9sBmbLJEYozsYyOwLcnrLSNaZxeCza1exyR
QIRogswoLDacxrYvO8FY6LtAEMkisv732M29zthBPm5wyoSZiM1X2YfQXKsmyh2h
x1/yCWv/BQjj68A8IAxToaXxSG4WAr/X00RGUkXgkgw122FxcmGuFyUCgYEA0TcR
dnt/oRMK4aCZHcBgTknzDfxKlJh4S0C9WjxKgr8IlW4LTeVSBuuqOObOQYImEhtw
TRTKZIViL0roDF79cioQSp1Tk5h6uy8wr6VyhWRnWfTz2/azoTHnmQ780rtAuEI/
NvE6FiqwikJLjma1YJoRfr/bfmgMdxcYbJI1MSsCgYAEZ5Yda1IKu1siFpcUNrdM
F5UvaWPc0WHzGEqARxye06UTL6K7yuqVwTBAteVaGlxYiSZTTDcGkHMDHuIzaRqO
HjWs2IA90VsC8Q4ABnHTKnx1F6nwlin8I774IP/GN8ooNwyuS63YWdJEYBy5RrC1
TQrODJjgD62DFdNUq7nmpQKBgFMJEzI+Q+KPJ0NztTG8t7x61y/W0Vb2yM+9Syn0
QfJwlZyRR4VMHelHQZFB8dzIJgoLv9+n/8gztEtm5IB8dwUHst2aYaBz5UpDqYQd
Gz3cIrTuZpcH7DVvFCeIbknJLh+zk1lgFpjTqqvFMi27kANeQtFWnmwmKcRec0As
K1ZvAoGAV/3YB44/zIoB590+yhpx2HTmDPVHH+J+5O71Pi1D9W13ClBFLrE69wo+
IQLIstBI5tGOGeuQNjXhDKJ1U30xppZXcnebrkA+oOo+6dy20zghFR2maAGXfWFU
pM4GsSnSTm0bXPebVouQFqhj7LqcQQzCqRDThmw/Lp1tJUmu40g=
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,9 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvOFmoEJFt1JkfdlwM3vJ
Fg5rrY9d6LyyqezjZkBZDQ4qdEEUdCrbW8ISFTtg9sfbrS3qingUzVP9VOfYPMC3
r0ugjJXjhvJdBSaoLlzL3saeyrXkfrOOvkcWKzeOynqUNPhKy9dchmuLALFfd/Jy
7Wzq0y7XxGeNidEmFjMAf9dwf6/+PjQjbG7zBFu/XSajITPHlDXPVDd0j2qw2wu5
Z9iqn4LRXnAFnC438hZZKZU/+JxU2ezr6Sefiy8XTC2kDiq3cgLeEjSywlJOs+4T
LjVS/3h75sh2Wk0xVaSwjPEjCOgma+2E3GJrGdQBiAjMSu101VBVwHUHaLDCn1T4
NwIDAQAB
-----END RSA PUBLIC KEY-----

View File

@ -0,0 +1,35 @@
---
###############################################################
# Users Database #
###############################################################
# This file can be used if you do not have an LDAP set up.
# List of users
users:
john:
displayname: "John Doe"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: john.doe@authelia.com
groups:
- admins
- dev
harry:
displayname: "Harry Potter"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: harry.potter@authelia.com
groups: []
bob:
displayname: "Bob Dylan"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: bob.dylan@authelia.com
groups:
- dev
james:
displayname: "James Dean"
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
email: james.dean@authelia.com
...

View File

@ -41,6 +41,9 @@ var MX1MailBaseURL = fmt.Sprintf("https://mx1.mail.%s", BaseDomain)
// MX2MailBaseURL the base URL of the mx2.mail domain. // MX2MailBaseURL the base URL of the mx2.mail domain.
var MX2MailBaseURL = fmt.Sprintf("https://mx2.mail.%s", BaseDomain) var MX2MailBaseURL = fmt.Sprintf("https://mx2.mail.%s", BaseDomain)
// OIDCBaseURL the base URL of the oidc domain.
var OIDCBaseURL = fmt.Sprintf("https://oidc.%s", BaseDomain)
// DuoBaseURL the base URL of the Duo configuration API. // DuoBaseURL the base URL of the Duo configuration API.
var DuoBaseURL = "https://duo.example.com" var DuoBaseURL = "https://duo.example.com"

View File

@ -45,6 +45,11 @@ func (de *DockerEnvironment) createCommand(cmd string) *exec.Cmd {
return utils.Command("bash", "-c", dockerCmdLine) return utils.Command("bash", "-c", dockerCmdLine)
} }
// Pull pull all images of needed in the environment.
func (de *DockerEnvironment) Pull(images ...string) error {
return de.createCommandWithStdout(fmt.Sprintf("pull %s", strings.Join(images, " "))).Run()
}
// Up spawn a docker environment. // Up spawn a docker environment.
func (de *DockerEnvironment) Up() error { func (de *DockerEnvironment) Up() error {
return de.createCommandWithStdout("up --build -d").Run() return de.createCommandWithStdout("up --build -d").Run()

View File

@ -24,7 +24,7 @@ services:
- 'traefik.frontend.rule=Host:login.example.com;PathPrefix:/api' - 'traefik.frontend.rule=Host:login.example.com;PathPrefix:/api'
- 'traefik.protocol=https' - 'traefik.protocol=https'
# Traefik 2.x # Traefik 2.x
- 'traefik.http.routers.authelia_backend.rule=Host(`login.example.com`) && PathPrefix(`/api`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/api/`)' # yamllint disable-line rule:line-length - 'traefik.http.routers.authelia_backend.rule=Host(`login.example.com`) && PathPrefix(`/.well-known/openid-configuration`) || Host(`login.example.com`) && PathPrefix(`/api`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/api/`)' # yamllint disable-line rule:line-length
- 'traefik.http.routers.authelia_backend.entrypoints=https' - 'traefik.http.routers.authelia_backend.entrypoints=https'
- 'traefik.http.routers.authelia_backend.tls=true' - 'traefik.http.routers.authelia_backend.tls=true'
- 'traefik.http.services.authelia_backend.loadbalancer.server.scheme=https' - 'traefik.http.services.authelia_backend.loadbalancer.server.scheme=https'

View File

@ -8,10 +8,11 @@ services:
- 'traefik.frontend.rule=Host:login.example.com;PathPrefix:/api' - 'traefik.frontend.rule=Host:login.example.com;PathPrefix:/api'
- 'traefik.protocol=https' - 'traefik.protocol=https'
# Traefik 2.x # Traefik 2.x
- 'traefik.http.routers.authelia_backend.rule=Host(`login.example.com`) && PathPrefix(`/api`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/api/`)' # yamllint disable-line rule:line-length - 'traefik.http.routers.authelia_backend.rule=Host(`login.example.com`) && PathPrefix(`/.well-known/openid-configuration`) || Host(`login.example.com`) && PathPrefix(`/api`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/api/`)' # yamllint disable-line rule:line-length
- 'traefik.http.routers.authelia_backend.entrypoints=https' - 'traefik.http.routers.authelia_backend.entrypoints=https'
- 'traefik.http.routers.authelia_backend.tls=true' - 'traefik.http.routers.authelia_backend.tls=true'
- 'traefik.http.services.authelia_backend.loadbalancer.server.scheme=https' - 'traefik.http.services.authelia_backend.loadbalancer.server.scheme=https'
- 'traefik.http.services.authelia_backend.passHostHeader=true'
volumes: volumes:
- '../..:/authelia' - '../..:/authelia'
environment: environment:

View File

@ -58,6 +58,9 @@
<li> <li>
mx2.main.example.com <a href="https://mx2.mail.example.com:8080/secret.html"> / secret.html</a> mx2.main.example.com <a href="https://mx2.mail.example.com:8080/secret.html"> / secret.html</a>
</li> </li>
<li>
oidc.example.com <a href="https://oidc.example.com:8080/">/</a> (only in OIDC suite).
</li>
</ul> </ul>
You can also log off by visiting the following <a You can also log off by visiting the following <a

View File

@ -34,6 +34,25 @@ http {
# Required by Authelia to build correct links for identity validation. # Required by Authelia to build correct links for identity validation.
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-URI $request_uri;
# Needed for network ACLs to work. It appends the IP of the client to the list of IPs
# and allows Authelia to use it to match the network-based ACLs.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_intercept_errors on;
proxy_pass $backend_endpoint;
}
location /.well-known/openid-configuration {
# Required by Authelia because "trust proxy" option is used.
# See https://expressjs.com/en/guide/behind-proxies.html
proxy_set_header X-Forwarded-Proto $scheme;
# Required by Authelia to build correct links for identity validation.
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-URI $request_uri;
# Needed for network ACLs to work. It appends the IP of the client to the list of IPs # Needed for network ACLs to work. It appends the IP of the client to the list of IPs
# and allows Authelia to use it to match the network-based ACLs. # and allows Authelia to use it to match the network-based ACLs.
@ -191,6 +210,74 @@ http {
} }
} }
# Example configuration of domains protected by Authelia.
server {
listen 8080 ssl;
server_name oidc.example.com
oidc-public.example.com;
resolver 127.0.0.11 ipv6=off;
set $upstream_verify https://authelia-backend:9091/api/verify;
set $upstream_endpoint http://oidc-client:8080;
ssl_certificate /etc/ssl/server.cert;
ssl_certificate_key /etc/ssl/server.key;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN";
error_page 497 301 =307 https://$host:$server_port$request_uri;
# Reverse proxy to the backend. It is protected by Authelia by forwarding authorization checks
# to the virtual endpoint introduced by nginx and declared in the next block.
location / {
auth_request /auth_verify;
# Route the request to the correct virtual host in the backend.
proxy_set_header Host $http_host;
# mitigate HTTPoxy Vulnerability
# https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/
proxy_set_header Proxy "";
# Set the `target_url` variable based on the request. It will be used to build the portal
# URL with the correct redirection parameter.
set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://login.example.com:8080/?rd=$target_url;
proxy_pass $upstream_endpoint;
}
# Virtual endpoint forwarding requests to Authelia server.
location /auth_verify {
internal;
proxy_set_header X-Real-IP $remote_addr;
# Provide either X-Original-URL and X-Forwarded-Proto or
# X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-URI or both.
# Those headers will be used by Authelia to deduce the target url of the user.
#
# X-Forwarded-Proto is mandatory since Authelia uses the "trust proxy" option.
# See https://expressjs.com/en/guide/behind-proxies.html
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-URI $request_uri;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Authelia can receive Proxy-Authorization to authenticate however most of the clients
# support Authorization instead. Therefore we rewrite Authorization into Proxy-Authorization.
proxy_set_header Proxy-Authorization $http_authorization;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass $upstream_verify;
}
}
# Fake Web Mail used to receive emails sent by Authelia. # Fake Web Mail used to receive emails sent by Authelia.
server { server {
listen 8080 ssl; listen 8080 ssl;

View File

@ -0,0 +1,20 @@
---
version: '3'
services:
oidc-client:
image: ghcr.io/authelia/oidc-tester-app:master-2a82ab3
command: /entrypoint.sh
depends_on:
- authelia-backend
volumes:
- ./example/compose/oidc-client/entrypoint.sh:/entrypoint.sh
expose:
- 8080
labels:
- 'traefik.http.routers.oidc.rule=Host(`oidc.example.com`)'
- 'traefik.http.routers.oidc.priority=150'
- 'traefik.http.routers.oidc.tls=true'
- 'traefik.http.routers.oidc.middlewares=authelia@docker'
networks:
- authelianet
...

View File

@ -0,0 +1,7 @@
#!/bin/bash
while true;
do
oidc-tester-app --issuer https://login.example.com:8080 --id oidc-tester-app --secret foobar --scopes openid,profile,email --redirect-domain oidc.example.com
sleep 5
done

View File

@ -26,6 +26,10 @@ services:
- '--serversTransport.insecureSkipVerify=true' - '--serversTransport.insecureSkipVerify=true'
networks: networks:
authelianet: authelianet:
aliases:
- public.example.com
- secure.example.com
- login.example.com
# Set the IP to be able to query on port 8080 # Set the IP to be able to query on port 8080
ipv4_address: 192.168.240.100 ipv4_address: 192.168.240.100
... ...

View File

@ -0,0 +1,117 @@
package suites
import (
"context"
"fmt"
"log"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type OIDCScenario struct {
*SeleniumSuite
secret string
}
func NewOIDCScenario() *OIDCScenario {
return &OIDCScenario{
SeleniumSuite: new(SeleniumSuite),
}
}
func (s *OIDCScenario) SetupSuite() {
wds, err := StartWebDriver()
if err != nil {
log.Fatal(err)
}
s.WebDriverSession = wds
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
s.secret = s.doRegisterAndLogin2FA(ctx, s.T(), "john", "password", false, AdminBaseURL)
}
func (s *OIDCScenario) TearDownSuite() {
err := s.WebDriverSession.Stop()
if err != nil {
log.Fatal(err)
}
}
func (s *OIDCScenario) SetupTest() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.doVisit(s.T(), fmt.Sprintf("%s/logout", OIDCBaseURL))
s.doLogout(ctx, s.T())
s.doVisit(s.T(), HomeBaseURL)
s.verifyIsHome(ctx, s.T())
}
func (s *OIDCScenario) TestShouldAuthorizeAccessToOIDCApp() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.doVisit(s.T(), OIDCBaseURL)
s.verifyIsFirstFactorPage(ctx, s.T())
s.doFillLoginPageAndClick(ctx, s.T(), "john", "password", false)
s.verifyIsSecondFactorPage(ctx, s.T())
s.doValidateTOTP(ctx, s.T(), s.secret)
time.Sleep(2 * time.Second)
s.waitBodyContains(ctx, s.T(), "Not logged yet...")
// this href represents the 'login' link
err := s.WaitElementLocatedByTagName(ctx, s.T(), "a").Click()
assert.NoError(s.T(), err)
s.verifyIsConsentPage(ctx, s.T())
err = s.WaitElementLocatedByID(ctx, s.T(), "accept-button").Click()
assert.NoError(s.T(), err)
// Verify that the app is showing the info related to the user stored in the JWT token
time.Sleep(2 * time.Second)
s.waitBodyContains(ctx, s.T(), "Logged in as john!")
}
func (s *OIDCScenario) TestShouldDenyConsent() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.doVisit(s.T(), OIDCBaseURL)
s.verifyIsFirstFactorPage(ctx, s.T())
s.doFillLoginPageAndClick(ctx, s.T(), "john", "password", false)
s.verifyIsSecondFactorPage(ctx, s.T())
s.doValidateTOTP(ctx, s.T(), s.secret)
time.Sleep(1 * time.Second)
s.waitBodyContains(ctx, s.T(), "Not logged yet...")
// this href represents the 'login' link
err := s.WaitElementLocatedByTagName(ctx, s.T(), "a").Click()
assert.NoError(s.T(), err)
s.verifyIsConsentPage(ctx, s.T())
err = s.WaitElementLocatedByID(ctx, s.T(), "deny-button").Click()
assert.NoError(s.T(), err)
time.Sleep(1 * time.Second)
s.verifyURLIs(ctx, s.T(), "https://oidc.example.com:8080/oauth2/callback?error=access_denied&error_description=User%20has%20rejected%20the%20scopes")
}
func TestRunOIDCScenario(t *testing.T) {
if testing.Short() {
t.Skip("skipping suite test in short mode")
}
suite.Run(t, NewOIDCSuite())
}

View File

@ -0,0 +1,80 @@
package suites
import (
"fmt"
"time"
)
var oidcSuiteName = "OIDC"
func init() {
dockerEnvironment := NewDockerEnvironment([]string{
"internal/suites/docker-compose.yml",
"internal/suites/OIDC/docker-compose.yml",
"internal/suites/example/compose/authelia/docker-compose.backend.{}.yml",
"internal/suites/example/compose/authelia/docker-compose.frontend.{}.yml",
"internal/suites/example/compose/nginx/backend/docker-compose.yml",
"internal/suites/example/compose/nginx/portal/docker-compose.yml",
"internal/suites/example/compose/smtp/docker-compose.yml",
"internal/suites/example/compose/oidc-client/docker-compose.yml",
"internal/suites/example/compose/redis/docker-compose.yml",
})
setup := func(suitePath string) error {
// TODO(c.michaud): use version in tags for oidc-client but in the meantime we pull the image to make sure it's
// up to date.
err := dockerEnvironment.Pull("oidc-client")
if err != nil {
return err
}
err = dockerEnvironment.Up()
if err != nil {
return err
}
return waitUntilAutheliaIsReady(dockerEnvironment, oidcSuiteName)
}
displayAutheliaLogs := func() error {
backendLogs, err := dockerEnvironment.Logs("authelia-backend", nil)
if err != nil {
return err
}
fmt.Println(backendLogs)
frontendLogs, err := dockerEnvironment.Logs("authelia-frontend", nil)
if err != nil {
return err
}
fmt.Println(frontendLogs)
oidcClientLogs, err := dockerEnvironment.Logs("oidc-client", nil)
if err != nil {
return err
}
fmt.Println(oidcClientLogs)
return nil
}
teardown := func(suitePath string) error {
err := dockerEnvironment.Down()
return err
}
GlobalRegistry.Register(oidcSuiteName, Suite{
SetUp: setup,
SetUpTimeout: 5 * time.Minute,
OnSetupTimeout: displayAutheliaLogs,
OnError: displayAutheliaLogs,
TestTimeout: 2 * time.Minute,
TearDown: teardown,
TearDownTimeout: 2 * time.Minute,
})
}

View File

@ -0,0 +1,23 @@
package suites
import (
"testing"
"github.com/stretchr/testify/suite"
)
type OIDCSuite struct {
*SeleniumSuite
}
func NewOIDCSuite() *OIDCSuite {
return &OIDCSuite{SeleniumSuite: new(SeleniumSuite)}
}
func (s *OIDCSuite) TestOIDCScenario() {
suite.Run(s.T(), NewOIDCScenario())
}
func TestOIDCSuite(t *testing.T) {
suite.Run(t, NewOIDCSuite())
}

View File

@ -0,0 +1,80 @@
package suites
import (
"fmt"
"time"
)
var oidcTraefikSuiteName = "OIDCTraefik"
func init() {
dockerEnvironment := NewDockerEnvironment([]string{
"internal/suites/docker-compose.yml",
"internal/suites/OIDCTraefik/docker-compose.yml",
"internal/suites/example/compose/authelia/docker-compose.backend.{}.yml",
"internal/suites/example/compose/authelia/docker-compose.frontend.{}.yml",
"internal/suites/example/compose/nginx/backend/docker-compose.yml",
"internal/suites/example/compose/traefik2/docker-compose.yml",
"internal/suites/example/compose/smtp/docker-compose.yml",
"internal/suites/example/compose/oidc-client/docker-compose.yml",
"internal/suites/example/compose/redis/docker-compose.yml",
})
setup := func(suitePath string) error {
// TODO(c.michaud): use version in tags for oidc-client but in the meantime we pull the image to make sure it's
// up to date.
err := dockerEnvironment.Pull("oidc-client")
if err != nil {
return err
}
err = dockerEnvironment.Up()
if err != nil {
return err
}
return waitUntilAutheliaIsReady(dockerEnvironment, oidcTraefikSuiteName)
}
displayAutheliaLogs := func() error {
backendLogs, err := dockerEnvironment.Logs("authelia-backend", nil)
if err != nil {
return err
}
fmt.Println(backendLogs)
frontendLogs, err := dockerEnvironment.Logs("authelia-frontend", nil)
if err != nil {
return err
}
fmt.Println(frontendLogs)
oidcClientLogs, err := dockerEnvironment.Logs("oidc-client", nil)
if err != nil {
return err
}
fmt.Println(oidcClientLogs)
return nil
}
teardown := func(suitePath string) error {
err := dockerEnvironment.Down()
return err
}
GlobalRegistry.Register(oidcTraefikSuiteName, Suite{
SetUp: setup,
SetUpTimeout: 5 * time.Minute,
OnSetupTimeout: displayAutheliaLogs,
OnError: displayAutheliaLogs,
TestTimeout: 2 * time.Minute,
TearDown: teardown,
TearDownTimeout: 2 * time.Minute,
})
}

View File

@ -0,0 +1,23 @@
package suites
import (
"testing"
"github.com/stretchr/testify/suite"
)
type OIDCTraefikSuite struct {
*SeleniumSuite
}
func NewOIDCTraefikSuite() *OIDCTraefikSuite {
return &OIDCTraefikSuite{SeleniumSuite: new(SeleniumSuite)}
}
func (s *OIDCTraefikSuite) TestOIDCScenario() {
suite.Run(s.T(), NewOIDCScenario())
}
func TestOIDCTraefikSuite(t *testing.T) {
suite.Run(t, NewOIDCTraefikSuite())
}

View File

@ -0,0 +1,10 @@
package suites
import (
"context"
"testing"
)
func (wds *WebDriverSession) verifyIsConsentPage(ctx context.Context, t *testing.T) {
wds.WaitElementLocatedByID(ctx, t, "consent-stage")
}

View File

@ -229,3 +229,16 @@ func (wds *WebDriverSession) WaitElementTextContains(ctx context.Context, t *tes
}) })
require.NoError(t, err) require.NoError(t, err)
} }
func (wds *WebDriverSession) waitBodyContains(ctx context.Context, t *testing.T, pattern string) {
err := wds.Wait(ctx, func(driver selenium.WebDriver) (bool, error) {
text, err := wds.WaitElementLocatedByTagName(ctx, t, "body").Text()
if err != nil {
return false, err
}
return strings.Contains(text, pattern), nil
})
require.NoError(t, err)
}

View File

@ -6,46 +6,47 @@ import (
"time" "time"
) )
const (
windows = "windows"
testStringInput = "abcdefghijkl"
// RFC3339Zero is the default value for time.Time.Unix().
RFC3339Zero = int64(-62135596800)
// TLS13 is the textual representation of TLS 1.3.
TLS13 = "1.3"
// TLS12 is the textual representation of TLS 1.2.
TLS12 = "1.2"
// TLS11 is the textual representation of TLS 1.1.
TLS11 = "1.1"
// TLS10 is the textual representation of TLS 1.0.
TLS10 = "1.0"
// Hour is an int based representation of the time unit.
Hour = time.Minute * 60
// Day is an int based representation of the time unit.
Day = Hour * 24
// Week is an int based representation of the time unit.
Week = Day * 7
// Year is an int based representation of the time unit.
Year = Day * 365
// Month is an int based representation of the time unit.
Month = Year / 12
)
// ErrTimeoutReached error thrown when a timeout is reached. // ErrTimeoutReached error thrown when a timeout is reached.
var ErrTimeoutReached = errors.New("timeout reached") var ErrTimeoutReached = errors.New("timeout reached")
var parseDurationRegexp = regexp.MustCompile(`^(?P<Duration>[1-9]\d*?)(?P<Unit>[smhdwMy])?$`) var parseDurationRegexp = regexp.MustCompile(`^(?P<Duration>[1-9]\d*?)(?P<Unit>[smhdwMy])?$`)
// Hour is an int based representation of the time unit.
const Hour = time.Minute * 60
// Day is an int based representation of the time unit.
const Day = Hour * 24
// Week is an int based representation of the time unit.
const Week = Day * 7
// Year is an int based representation of the time unit.
const Year = Day * 365
// Month is an int based representation of the time unit.
const Month = Year / 12
const windows = "windows"
// RFC3339Zero is the default value for time.Time.Unix().
const RFC3339Zero = int64(-62135596800)
const testStringInput = "abcdefghijkl"
// AlphaNumericCharacters are literally just valid alphanumeric chars. // AlphaNumericCharacters are literally just valid alphanumeric chars.
var AlphaNumericCharacters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") var AlphaNumericCharacters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
// ErrTLSVersionNotSupported returned when an unknown TLS version supplied. // ErrTLSVersionNotSupported returned when an unknown TLS version supplied.
var ErrTLSVersionNotSupported = errors.New("supplied TLS version isn't supported") var ErrTLSVersionNotSupported = errors.New("supplied TLS version isn't supported")
// TLS13 is the textual representation of TLS 1.3.
const TLS13 = "1.3"
// TLS12 is the textual representation of TLS 1.2.
const TLS12 = "1.2"
// TLS11 is the textual representation of TLS 1.1.
const TLS11 = "1.1"
// TLS10 is the textual representation of TLS 1.0.
const TLS10 = "1.0"

View File

@ -1,12 +1,49 @@
package utils package utils
import ( import (
"errors"
"os" "os"
) )
// FileExists returns whether the given file or directory exists. // FileExists returns true if the given path exists and is a file.
func FileExists(path string) (bool, error) { func FileExists(path string) (exists bool, err error) {
_, err := os.Stat(path) info, err := os.Stat(path)
if err == nil {
if info.IsDir() {
return false, errors.New("path is a directory")
}
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// DirectoryExists returns true if the given path exists and is a directory.
func DirectoryExists(path string) (exists bool, err error) {
info, err := os.Stat(path)
if err == nil {
if info.IsDir() {
return true, nil
}
return false, errors.New("path is a file")
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// PathExists returns true if the given path exists.
func PathExists(path string) (exists bool, err error) {
_, err = os.Stat(path)
if err == nil { if err == nil {
return true, nil return true, nil
} }

View File

@ -0,0 +1,56 @@
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestShouldCheckIfFileExists(t *testing.T) {
exists, err := FileExists("../../README.md")
assert.NoError(t, err)
assert.True(t, exists)
exists, err = FileExists("../../")
assert.EqualError(t, err, "path is a directory")
assert.False(t, exists)
exists, err = FileExists("../../NOTAFILE.md")
assert.NoError(t, err)
assert.False(t, exists)
}
func TestShouldCheckIfDirectoryExists(t *testing.T) {
exists, err := DirectoryExists("../../")
assert.NoError(t, err)
assert.True(t, exists)
exists, err = DirectoryExists("../../README.md")
assert.EqualError(t, err, "path is a file")
assert.False(t, exists)
exists, err = DirectoryExists("../../NOTADIRECTORY/")
assert.NoError(t, err)
assert.False(t, exists)
}
func TestShouldCheckIfPathExists(t *testing.T) {
exists, err := PathExists("../../README.md")
assert.NoError(t, err)
assert.True(t, exists)
exists, err = PathExists("../../")
assert.NoError(t, err)
assert.True(t, exists)
exists, err = PathExists("../../NOTAFILE.md")
assert.NoError(t, err)
assert.False(t, exists)
exists, err = PathExists("../../NOTADIRECTORY/")
assert.NoError(t, err)
assert.False(t, exists)
}

View File

@ -0,0 +1,13 @@
package utils
import (
"crypto/sha256"
"fmt"
)
// HashSHA256FromString takes an input string and calculates the SHA256 checksum returning it as a base16 hash string.
func HashSHA256FromString(input string) (output string) {
sum := sha256.Sum256([]byte(input))
return fmt.Sprintf("%x", sum)
}

View File

@ -0,0 +1,83 @@
package utils
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
)
// GenerateRsaKeyPair generate an RSA key pair.
// bits can be 2048 or 4096.
func GenerateRsaKeyPair(bits int) (*rsa.PrivateKey, *rsa.PublicKey) {
privkey, _ := rsa.GenerateKey(rand.Reader, bits)
return privkey, &privkey.PublicKey
}
// ExportRsaPrivateKeyAsPemStr marshal a rsa private key into PEM string.
func ExportRsaPrivateKeyAsPemStr(privkey *rsa.PrivateKey) string {
privkeyBytes := x509.MarshalPKCS1PrivateKey(privkey)
privkeyPem := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privkeyBytes,
},
)
return string(privkeyPem)
}
// ParseRsaPrivateKeyFromPemStr parse a RSA private key from PEM string.
func ParseRsaPrivateKeyFromPemStr(privPEM string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(privPEM))
if block == nil {
return nil, errors.New("failed to parse PEM block containing the key")
}
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
return priv, nil
}
// ExportRsaPublicKeyAsPemStr marshal a RSA public into a PEM string.
func ExportRsaPublicKeyAsPemStr(pubkey *rsa.PublicKey) (string, error) {
pubkeyBytes, err := x509.MarshalPKIXPublicKey(pubkey)
if err != nil {
return "", err
}
pubkeyPem := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: pubkeyBytes,
},
)
return string(pubkeyPem), nil
}
// ParseRsaPublicKeyFromPemStr parse RSA public key from a PEM string.
func ParseRsaPublicKeyFromPemStr(pubPEM string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pubPEM))
if block == nil {
return nil, errors.New("failed to parse PEM block containing the key")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
switch pub := pub.(type) {
case *rsa.PublicKey:
return pub, nil
default:
break // fall through
}
return nil, errors.New("Key type is not RSA")
}

View File

@ -68,15 +68,13 @@ func SliceString(s string, d int) (array []string) {
return return
} }
// IsStringSlicesDifferent checks two slices of strings and on the first occurrence of a string item not existing in the func isStringSlicesDifferent(a, b []string, method func(s string, b []string) bool) (different bool) {
// other slice returns true, otherwise returns false.
func IsStringSlicesDifferent(a, b []string) (different bool) {
if len(a) != len(b) { if len(a) != len(b) {
return true return true
} }
for _, s := range a { for _, s := range a {
if !IsStringInSlice(s, b) { if !method(s, b) {
return true return true
} }
} }
@ -84,6 +82,18 @@ func IsStringSlicesDifferent(a, b []string) (different bool) {
return false return false
} }
// IsStringSlicesDifferent checks two slices of strings and on the first occurrence of a string item not existing in the
// other slice returns true, otherwise returns false.
func IsStringSlicesDifferent(a, b []string) (different bool) {
return isStringSlicesDifferent(a, b, IsStringInSlice)
}
// IsStringSlicesDifferentFold checks two slices of strings and on the first occurrence of a string item not existing in
// the other slice (case insensitive) returns true, otherwise returns false.
func IsStringSlicesDifferentFold(a, b []string) (different bool) {
return isStringSlicesDifferent(a, b, IsStringInSliceFold)
}
// StringSlicesDelta takes a before and after []string and compares them returning a added and removed []string. // StringSlicesDelta takes a before and after []string and compares them returning a added and removed []string.
func StringSlicesDelta(before, after []string) (added, removed []string) { func StringSlicesDelta(before, after []string) (added, removed []string) {
for _, s := range before { for _, s := range before {

View File

@ -7,6 +7,12 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestShouldDetectAlphaNumericString(t *testing.T) {
assert.True(t, IsStringAlphaNumeric("abc"))
assert.True(t, IsStringAlphaNumeric("abc123"))
assert.False(t, IsStringAlphaNumeric("abc123@"))
}
func TestShouldSplitIntoEvenStringsOfFour(t *testing.T) { func TestShouldSplitIntoEvenStringsOfFour(t *testing.T) {
input := testStringInput input := testStringInput
@ -70,6 +76,12 @@ func TestShouldFindSliceDifferences(t *testing.T) {
b := []string{"abc", "xyz"} b := []string{"abc", "xyz"}
assert.True(t, IsStringSlicesDifferent(a, b)) assert.True(t, IsStringSlicesDifferent(a, b))
assert.True(t, IsStringSlicesDifferentFold(a, b))
c := []string{"Abc", "xyz"}
assert.True(t, IsStringSlicesDifferent(b, c))
assert.False(t, IsStringSlicesDifferentFold(b, c))
} }
func TestShouldNotFindSliceDifferences(t *testing.T) { func TestShouldNotFindSliceDifferences(t *testing.T) {
@ -77,6 +89,7 @@ func TestShouldNotFindSliceDifferences(t *testing.T) {
b := []string{"abc", "onetwothree"} b := []string{"abc", "onetwothree"}
assert.False(t, IsStringSlicesDifferent(a, b)) assert.False(t, IsStringSlicesDifferent(a, b))
assert.False(t, IsStringSlicesDifferentFold(a, b))
} }
func TestShouldFindSliceDifferenceWhenDifferentLength(t *testing.T) { func TestShouldFindSliceDifferenceWhenDifferentLength(t *testing.T) {
@ -84,6 +97,7 @@ func TestShouldFindSliceDifferenceWhenDifferentLength(t *testing.T) {
b := []string{"abc", "onetwothree", "more"} b := []string{"abc", "onetwothree", "more"}
assert.True(t, IsStringSlicesDifferent(a, b)) assert.True(t, IsStringSlicesDifferent(a, b))
assert.True(t, IsStringSlicesDifferentFold(a, b))
} }
func TestShouldFindStringInSliceContains(t *testing.T) { func TestShouldFindStringInSliceContains(t *testing.T) {

View File

@ -14,12 +14,14 @@ import {
RegisterSecurityKeyRoute, RegisterSecurityKeyRoute,
RegisterOneTimePasswordRoute, RegisterOneTimePasswordRoute,
LogoutRoute, LogoutRoute,
ConsentRoute,
} from "./Routes"; } from "./Routes";
import * as themes from "./themes"; import * as themes from "./themes";
import { getBasePath } from "./utils/BasePath"; import { getBasePath } from "./utils/BasePath";
import { getRememberMe, getResetPassword, getTheme } from "./utils/Configuration"; import { getRememberMe, getResetPassword, getTheme } from "./utils/Configuration";
import RegisterOneTimePassword from "./views/DeviceRegistration/RegisterOneTimePassword"; import RegisterOneTimePassword from "./views/DeviceRegistration/RegisterOneTimePassword";
import RegisterSecurityKey from "./views/DeviceRegistration/RegisterSecurityKey"; import RegisterSecurityKey from "./views/DeviceRegistration/RegisterSecurityKey";
import ConsentView from "./views/LoginPortal/ConsentView/ConsentView";
import LoginPortal from "./views/LoginPortal/LoginPortal"; import LoginPortal from "./views/LoginPortal/LoginPortal";
import SignOut from "./views/LoginPortal/SignOut/SignOut"; import SignOut from "./views/LoginPortal/SignOut/SignOut";
import ResetPasswordStep1 from "./views/ResetPassword/ResetPasswordStep1"; import ResetPasswordStep1 from "./views/ResetPassword/ResetPasswordStep1";
@ -65,6 +67,9 @@ const App: React.FC = () => {
<Route path={LogoutRoute} exact> <Route path={LogoutRoute} exact>
<SignOut /> <SignOut />
</Route> </Route>
<Route path={ConsentRoute} exact>
<ConsentView />
</Route>
<Route path={FirstFactorRoute}> <Route path={FirstFactorRoute}>
<LoginPortal rememberMe={getRememberMe()} resetPassword={getResetPassword()} /> <LoginPortal rememberMe={getRememberMe()} resetPassword={getResetPassword()} />
</Route> </Route>

View File

@ -1,13 +1,14 @@
export const FirstFactorRoute = "/"; export const FirstFactorRoute: string = "/";
export const AuthenticatedRoute = "/authenticated"; export const AuthenticatedRoute: string = "/authenticated";
export const ConsentRoute: string = "/consent";
export const SecondFactorRoute = "/2fa"; export const SecondFactorRoute: string = "/2fa";
export const SecondFactorU2FRoute = "/2fa/security-key"; export const SecondFactorU2FRoute: string = "/2fa/security-key";
export const SecondFactorTOTPRoute = "/2fa/one-time-password"; export const SecondFactorTOTPRoute: string = "/2fa/one-time-password";
export const SecondFactorPushRoute = "/2fa/push-notification"; export const SecondFactorPushRoute: string = "/2fa/push-notification";
export const ResetPasswordStep1Route = "/reset-password/step1"; export const ResetPasswordStep1Route: string = "/reset-password/step1";
export const ResetPasswordStep2Route = "/reset-password/step2"; export const ResetPasswordStep2Route: string = "/reset-password/step2";
export const RegisterSecurityKeyRoute = "/security-key/register"; export const RegisterSecurityKeyRoute: string = "/security-key/register";
export const RegisterOneTimePasswordRoute = "/one-time-password/register"; export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
export const LogoutRoute = "/logout"; export const LogoutRoute: string = "/logout";

View File

@ -18,7 +18,7 @@ const NotificationBar = function (props: Props) {
if (notification && notification !== null) { if (notification && notification !== null) {
setTmpNotification(notification); setTmpNotification(notification);
} }
}, [notification]); }, [notification, setTmpNotification]);
const shouldSnackbarBeOpen = notification !== undefined && notification !== null; const shouldSnackbarBeOpen = notification !== undefined && notification !== null;

View File

@ -0,0 +1,6 @@
import { getRequestedScopes } from "../services/Consent";
import { useRemoteCall } from "./RemoteCall";
export function useRequestedScopes() {
return useRemoteCall(getRequestedScopes, []);
}

View File

@ -0,0 +1,5 @@
export function useRedirector() {
return (url: string) => {
window.location.href = url;
};
}

Some files were not shown because too many files have changed in this diff Show More