feat: Support dynamic redirect_uris based on incoming requests (#1227)

To enable this feature you need `CONSOLE_IDP_CALLBACK_DYNAMIC=on`

```
export CONSOLE_IDP_URL=https://gitlab.com/.well-known/openid-configuration
export CONSOLE_IDP_CLIENT_ID="b0088c3836bb029393942f71ed7c8ac0add7f0856e6c86e67b0ff98f85c48658"
export CONSOLE_IDP_SECRET="ed72087b37624e89816ac27c1355420902045274edd7baad2ae29b1b0e8436fe"
export CONSOLE_IDP_SCOPES="openid,profile,email"
export CONSOLE_IDP_USERINFO="on"
export CONSOLE_IDP_CALLBACK_DYNAMIC=on
console srv
```

if this becomes a common practice, we should enable this as default in future.
This commit is contained in:
Harshavardhana
2021-11-15 12:45:09 -08:00
committed by GitHub
parent b8417fb7a0
commit 373bfbfe3f
6 changed files with 132 additions and 28 deletions

View File

@@ -39,7 +39,7 @@ import (
func registerLoginHandlers(api *operations.OperatorAPI) {
// GET login strategy
api.UserAPILoginDetailHandler = user_api.LoginDetailHandlerFunc(func(params user_api.LoginDetailParams) middleware.Responder {
loginDetails, err := getLoginDetailsResponse()
loginDetails, err := getLoginDetailsResponse(params.HTTPRequest)
if err != nil {
return user_api.NewLoginDetailDefault(int(err.Code)).WithPayload(err)
}
@@ -60,7 +60,7 @@ func registerLoginHandlers(api *operations.OperatorAPI) {
})
// POST login using external IDP
api.UserAPILoginOauth2AuthHandler = user_api.LoginOauth2AuthHandlerFunc(func(params user_api.LoginOauth2AuthParams) middleware.Responder {
loginResponse, err := getLoginOauth2AuthResponse(params.Body)
loginResponse, err := getLoginOauth2AuthResponse(params.HTTPRequest, params.Body)
if err != nil {
return user_api.NewLoginOauth2AuthDefault(int(err.Code)).WithPayload(err)
}
@@ -91,14 +91,14 @@ func login(credentials restapi.ConsoleCredentialsI) (*string, error) {
}
// getLoginDetailsResponse returns information regarding the Console authentication mechanism.
func getLoginDetailsResponse() (*models.LoginDetails, *models.Error) {
func getLoginDetailsResponse(r *http.Request) (*models.LoginDetails, *models.Error) {
loginStrategy := models.LoginDetailsLoginStrategyServiceDashAccount
redirectURL := ""
if oauth2.IsIDPEnabled() {
loginStrategy = models.LoginDetailsLoginStrategyRedirect
// initialize new oauth2 client
oauth2Client, err := oauth2.NewOauth2ProviderClient(nil, restapi.GetConsoleHTTPClient())
oauth2Client, err := oauth2.NewOauth2ProviderClient(nil, r, restapi.GetConsoleHTTPClient())
if err != nil {
return nil, prepareError(err)
}
@@ -123,12 +123,12 @@ func verifyUserAgainstIDP(ctx context.Context, provider auth.IdentityProviderI,
return oauth2Token, nil
}
func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.LoginResponse, *models.Error) {
func getLoginOauth2AuthResponse(r *http.Request, lr *models.LoginOauth2AuthRequest) (*models.LoginResponse, *models.Error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if oauth2.IsIDPEnabled() {
// initialize new oauth2 client
oauth2Client, err := oauth2.NewOauth2ProviderClient(nil, restapi.GetConsoleHTTPClient())
oauth2Client, err := oauth2.NewOauth2ProviderClient(nil, r, restapi.GetConsoleHTTPClient())
if err != nil {
return nil, prepareError(err)
}

View File

@@ -45,15 +45,19 @@ func GetIDPSecret() string {
return env.Get(ConsoleIDPSecret, "")
}
// Public endpoint used by the identity oidcProvider when redirecting the user after identity verification
// Public endpoint used by the identity oidcProvider when redirecting
// the user after identity verification
func GetIDPCallbackURL() string {
return env.Get(ConsoleIDPCallbackURL, "")
}
func GetIDPCallbackURLDynamic() bool {
return env.Get(ConsoleIDPCallbackURLDynamic, "") == "on"
}
func IsIDPEnabled() bool {
return GetIDPURL() != "" &&
GetIDPClientID() != "" &&
GetIDPCallbackURL() != ""
GetIDPClientID() != ""
}
var defaultPassphraseForIDPHmac = utils.RandomCharString(64)

View File

@@ -18,14 +18,15 @@ package oauth2
// Environment constants for console IDP/SSO configuration
const (
ConsoleMinIOServer = "CONSOLE_MINIO_SERVER"
ConsoleIDPURL = "CONSOLE_IDP_URL"
ConsoleIDPClientID = "CONSOLE_IDP_CLIENT_ID"
ConsoleIDPSecret = "CONSOLE_IDP_SECRET"
ConsoleIDPCallbackURL = "CONSOLE_IDP_CALLBACK"
ConsoleIDPHmacPassphrase = "CONSOLE_IDP_HMAC_PASSPHRASE"
ConsoleIDPHmacSalt = "CONSOLE_IDP_HMAC_SALT"
ConsoleIDPScopes = "CONSOLE_IDP_SCOPES"
ConsoleIDPUserInfo = "CONSOLE_IDP_USERINFO"
ConsoleIDPTokenExpiration = "CONSOLE_IDP_TOKEN_EXPIRATION"
ConsoleMinIOServer = "CONSOLE_MINIO_SERVER"
ConsoleIDPURL = "CONSOLE_IDP_URL"
ConsoleIDPClientID = "CONSOLE_IDP_CLIENT_ID"
ConsoleIDPSecret = "CONSOLE_IDP_SECRET"
ConsoleIDPCallbackURL = "CONSOLE_IDP_CALLBACK"
ConsoleIDPCallbackURLDynamic = "CONSOLE_IDP_CALLBACK_DYNAMIC"
ConsoleIDPHmacPassphrase = "CONSOLE_IDP_HMAC_PASSPHRASE"
ConsoleIDPHmacSalt = "CONSOLE_IDP_HMAC_SALT"
ConsoleIDPScopes = "CONSOLE_IDP_SCOPES"
ConsoleIDPUserInfo = "CONSOLE_IDP_USERINFO"
ConsoleIDPTokenExpiration = "CONSOLE_IDP_TOKEN_EXPIRATION"
)

View File

@@ -119,11 +119,33 @@ var derivedKey = func() []byte {
return pbkdf2.Key([]byte(getPassphraseForIDPHmac()), []byte(getSaltForIDPHmac()), 4096, 32, sha1.New)
}
const (
schemeHTTP = "http"
schemeHTTPS = "https"
)
func getLoginCallbackURL(r *http.Request) string {
scheme := getSourceScheme(r)
if scheme == "" {
if r.TLS != nil {
scheme = schemeHTTPS
} else {
scheme = schemeHTTP
}
}
redirectURL := scheme + "://" + r.Host + "/oauth_callback"
_, err := url.Parse(redirectURL)
if err != nil {
panic(err)
}
return redirectURL
}
// NewOauth2ProviderClient instantiates a new oauth2 client using the configured credentials
// it returns a *Provider object that contains the necessary configuration to initiate an
// oauth2 authentication flow
func NewOauth2ProviderClient(scopes []string, httpClient *http.Client) (*Provider, error) {
func NewOauth2ProviderClient(scopes []string, r *http.Request, httpClient *http.Client) (*Provider, error) {
ddoc, err := parseDiscoveryDoc(GetIDPURL(), httpClient)
if err != nil {
return nil, err
@@ -134,6 +156,13 @@ func NewOauth2ProviderClient(scopes []string, httpClient *http.Client) (*Provide
scopes = strings.Split(getIDPScopes(), ",")
}
redirectURL := GetIDPCallbackURL()
if GetIDPCallbackURLDynamic() {
// dynamic redirect if set, will generate redirect URLs
// dynamically based on incoming requests.
redirectURL = getLoginCallbackURL(r)
}
// add "openid" scope always.
scopes = append(scopes, "openid")
@@ -141,7 +170,7 @@ func NewOauth2ProviderClient(scopes []string, httpClient *http.Client) (*Provide
client.oauth2Config = &xoauth2.Config{
ClientID: GetIDPClientID(),
ClientSecret: GetIDPSecret(),
RedirectURL: GetIDPCallbackURL(),
RedirectURL: redirectURL,
Endpoint: oauth2.Endpoint{
AuthURL: ddoc.AuthEndpoint,
TokenURL: ddoc.TokenEndpoint,

View File

@@ -0,0 +1,70 @@
// This file is part of MinIO Console Server
// Copyright (c) 2021 MinIO, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package oauth2
import (
"net/http"
"regexp"
"strings"
)
var (
// De-facto standard header keys.
xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto")
xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme")
)
var (
// RFC7239 defines a new "Forwarded: " header designed to replace the
// existing use of X-Forwarded-* headers.
// e.g. Forwarded: for=192.0.2.60;proto=https;by=203.0.113.43
forwarded = http.CanonicalHeaderKey("Forwarded")
// Allows for a sub-match of the first value after 'for=' to the next
// comma, semi-colon or space. The match is case-insensitive.
forRegex = regexp.MustCompile(`(?i)(?:for=)([^(;|,| )]+)(.*)`)
// Allows for a sub-match for the first instance of scheme (http|https)
// prefixed by 'proto='. The match is case-insensitive.
protoRegex = regexp.MustCompile(`(?i)^(;|,| )+(?:proto=)(https|http)`)
)
// getSourceScheme retrieves the scheme from the X-Forwarded-Proto and RFC7239
// Forwarded headers (in that order).
func getSourceScheme(r *http.Request) string {
var scheme string
// Retrieve the scheme from X-Forwarded-Proto.
if proto := r.Header.Get(xForwardedProto); proto != "" {
scheme = strings.ToLower(proto)
} else if proto = r.Header.Get(xForwardedScheme); proto != "" {
scheme = strings.ToLower(proto)
} else if proto := r.Header.Get(forwarded); proto != "" {
// match should contain at least two elements if the protocol was
// specified in the Forwarded header. The first element will always be
// the 'for=', which we ignore, subsequently we proceed to look for
// 'proto=' which should precede right after `for=` if not
// we simply ignore the values and return empty. This is in line
// with the approach we took for returning first ip from multiple
// params.
if match := forRegex.FindStringSubmatch(proto); len(match) > 1 {
if match = protoRegex.FindStringSubmatch(match[2]); len(match) > 1 {
scheme = strings.ToLower(match[2])
}
}
}
return scheme
}

View File

@@ -38,7 +38,7 @@ import (
func registerLoginHandlers(api *operations.ConsoleAPI) {
// GET login strategy
api.UserAPILoginDetailHandler = user_api.LoginDetailHandlerFunc(func(params user_api.LoginDetailParams) middleware.Responder {
loginDetails, err := getLoginDetailsResponse()
loginDetails, err := getLoginDetailsResponse(params.HTTPRequest)
if err != nil {
return user_api.NewLoginDetailDefault(int(err.Code)).WithPayload(err)
}
@@ -59,7 +59,7 @@ func registerLoginHandlers(api *operations.ConsoleAPI) {
})
// POST login using external IDP
api.UserAPILoginOauth2AuthHandler = user_api.LoginOauth2AuthHandlerFunc(func(params user_api.LoginOauth2AuthParams) middleware.Responder {
loginResponse, err := getLoginOauth2AuthResponse(params.Body)
loginResponse, err := getLoginOauth2AuthResponse(params.HTTPRequest, params.Body)
if err != nil {
return user_api.NewLoginOauth2AuthDefault(int(err.Code)).WithPayload(err)
}
@@ -131,14 +131,14 @@ func getLoginResponse(lr *models.LoginRequest) (*models.LoginResponse, *models.E
}
// getLoginDetailsResponse returns information regarding the Console authentication mechanism.
func getLoginDetailsResponse() (*models.LoginDetails, *models.Error) {
func getLoginDetailsResponse(r *http.Request) (*models.LoginDetails, *models.Error) {
loginStrategy := models.LoginDetailsLoginStrategyForm
redirectURL := ""
if oauth2.IsIDPEnabled() {
loginStrategy = models.LoginDetailsLoginStrategyRedirect
// initialize new oauth2 client
oauth2Client, err := oauth2.NewOauth2ProviderClient(nil, GetConsoleHTTPClient())
oauth2Client, err := oauth2.NewOauth2ProviderClient(nil, r, GetConsoleHTTPClient())
if err != nil {
return nil, prepareError(err, errOauth2Provider)
}
@@ -164,12 +164,12 @@ func verifyUserAgainstIDP(ctx context.Context, provider auth.IdentityProviderI,
return userCredentials, nil
}
func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.LoginResponse, *models.Error) {
func getLoginOauth2AuthResponse(r *http.Request, lr *models.LoginOauth2AuthRequest) (*models.LoginResponse, *models.Error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if oauth2.IsIDPEnabled() {
// initialize new oauth2 client
oauth2Client, err := oauth2.NewOauth2ProviderClient(nil, GetConsoleHTTPClient())
oauth2Client, err := oauth2.NewOauth2ProviderClient(nil, r, GetConsoleHTTPClient())
if err != nil {
return nil, prepareError(err)
}