github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/pkg/auth/dex.go (about)

     1  /*This file is part of kuberpult.
     2  
     3  Kuberpult is free software: you can redistribute it and/or modify
     4  it under the terms of the Expat(MIT) License as published by
     5  the Free Software Foundation.
     6  
     7  Kuberpult is distributed in the hope that it will be useful,
     8  but WITHOUT ANY WARRANTY; without even the implied warranty of
     9  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    10  MIT License for more details.
    11  
    12  You should have received a copy of the MIT License
    13  along with kuberpult. If not, see <https://directory.fsf.org/wiki/License:Expat>.
    14  
    15  Copyright 2023 freiheit.com*/
    16  
    17  package auth
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"fmt"
    23  	"html"
    24  	"io"
    25  	"net/http"
    26  	"net/http/httputil"
    27  	"net/url"
    28  	"strings"
    29  	"time"
    30  
    31  	"github.com/coreos/go-oidc/v3/oidc"
    32  	"github.com/freiheit-com/kuberpult/pkg/logger"
    33  	jwt "github.com/golang-jwt/jwt/v5"
    34  	"golang.org/x/oauth2"
    35  )
    36  
    37  // Extracted information from JWT/Cookie.
    38  type DexAuthContext struct {
    39  	// The user role extracted from the Cookie.
    40  	Role string
    41  }
    42  
    43  // Dex App Client.
    44  type DexAppClient struct {
    45  	// The Dex issuer URL. Needs to be match the dex issuer helm config.
    46  	IssuerURL string
    47  	// The host Kuberpult is running on.
    48  	BaseURL string
    49  	// The Kuberpult client ID. Needs to match the dex staticClients.id helm configuration.
    50  	ClientID string
    51  	// The Kuberpult client secret. Needs to match the dex staticClients.secret helm configuration.
    52  	ClientSecret string
    53  	// The Dex redirect callback. Needs to match the dex staticClients.redirectURIs helm configuration.
    54  	RedirectURI string
    55  	// The available scopes.
    56  	Scopes []string
    57  	// The http client used.
    58  	Client *http.Client
    59  }
    60  
    61  const (
    62  	// Dex service internal URL. Used to connect to dex internally in the cluster.
    63  	dexServiceURL = "http://kuberpult-dex-service:5556"
    64  	// Dex issuer path. Needs to be match the dex issuer helm config.
    65  	issuerPATH = "/dex"
    66  	// Dex callback path. Needs to be match the dex staticClients.redirectURIs helm config.
    67  	callbackPATH = "/callback"
    68  	// Kuberpult login path.
    69  	LoginPATH = "/login"
    70  	// Dex OAUTH token name.
    71  	dexOAUTHTokenName = "kuberpult.oauth"
    72  	// Default value for the number of days the token is valid for.
    73  	expirationDays = 1
    74  )
    75  
    76  // NewDexAppClient a Dex Client.
    77  func NewDexAppClient(clientID, clientSecret, baseURL string, scopes []string) (*DexAppClient, error) {
    78  	a := DexAppClient{
    79  		Client:       nil,
    80  		ClientID:     clientID,
    81  		ClientSecret: clientSecret,
    82  		Scopes:       scopes,
    83  		BaseURL:      baseURL,
    84  		RedirectURI:  baseURL + callbackPATH,
    85  		IssuerURL:    baseURL + issuerPATH,
    86  	}
    87  	//exhaustruct:ignore
    88  	transport := &http.Transport{
    89  		Proxy: http.ProxyFromEnvironment,
    90  	}
    91  	//exhaustruct:ignore
    92  	a.Client = &http.Client{
    93  		Transport: transport,
    94  	}
    95  
    96  	// Creates a transport layer to map all requests to dex internally
    97  	dexURL, _ := url.Parse(dexServiceURL)
    98  	a.Client.Transport = DexRewriteURLRoundTripper{
    99  		DexURL: dexURL,
   100  		T:      a.Client.Transport,
   101  	}
   102  
   103  	// Register Dex handlers.
   104  	a.registerDexHandlers()
   105  	return &a, nil
   106  }
   107  
   108  // DexRewriteURLRoundTripper creates a new DexRewriteURLRoundTripper.
   109  // The round tripper is configured to avoid exposing the dex server via a virtual service. Since Kuberpult and dex
   110  // are running on the same cluster, a reverse proxy is configured to redirect all dex calls internally.
   111  type DexRewriteURLRoundTripper struct {
   112  	DexURL *url.URL
   113  	T      http.RoundTripper
   114  }
   115  
   116  func (s DexRewriteURLRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
   117  	r.URL.Host = s.DexURL.Host
   118  	r.URL.Scheme = s.DexURL.Scheme
   119  	r.Host = s.DexURL.Host
   120  	return s.T.RoundTrip(r)
   121  }
   122  
   123  // Registers dex handlers for login
   124  func (a *DexAppClient) registerDexHandlers() {
   125  	// Handles calls to the Dex server. Calls are redirected internally using a reverse proxy.
   126  	http.HandleFunc(issuerPATH+"/", NewDexReverseProxy(dexServiceURL))
   127  	// Handles the login callback to redirect to dex page.
   128  	http.HandleFunc(LoginPATH, a.handleDexLogin)
   129  	// Call back to the current app once the login is finished
   130  	http.HandleFunc(callbackPATH, a.handleCallback)
   131  }
   132  
   133  // NewDexReverseProxy returns a reverse proxy to the Dex server.
   134  func NewDexReverseProxy(serverAddr string) func(writer http.ResponseWriter, request *http.Request) {
   135  	target, err := url.Parse(serverAddr)
   136  	if err != nil {
   137  		logger.FromContext(context.Background()).Error(fmt.Sprintf("Could not parse server URL with error: %s", err))
   138  		return nil
   139  	}
   140  
   141  	proxy := httputil.NewSingleHostReverseProxy(target)
   142  	proxy.ModifyResponse = func(resp *http.Response) error {
   143  		if resp.StatusCode == http.StatusInternalServerError {
   144  			body, err := io.ReadAll(resp.Body)
   145  			if err != nil {
   146  				return err
   147  			}
   148  			err = resp.Body.Close()
   149  			if err != nil {
   150  				return err
   151  			}
   152  			logger.FromContext(context.Background()).Error(fmt.Sprintf("Could not parse server URL with error: %s", string(body)))
   153  			resp.Body = io.NopCloser(bytes.NewReader(make([]byte, 0)))
   154  			return nil
   155  		}
   156  		return nil
   157  	}
   158  	proxy.Director = decorateDirector(proxy.Director, target)
   159  	return func(w http.ResponseWriter, r *http.Request) {
   160  		proxy.ServeHTTP(w, r)
   161  	}
   162  }
   163  
   164  func decorateDirector(director func(req *http.Request), target *url.URL) func(req *http.Request) {
   165  	return func(req *http.Request) {
   166  		director(req)
   167  		req.Host = target.Host
   168  	}
   169  }
   170  
   171  // Redirects to the Dex login page with the pre configured connector.
   172  func (a *DexAppClient) handleDexLogin(w http.ResponseWriter, r *http.Request) {
   173  	oauthConfig, err := a.oauth2Config(a.Scopes)
   174  	if err != nil {
   175  		http.Error(w, err.Error(), http.StatusInternalServerError)
   176  		return
   177  	}
   178  
   179  	// TODO(BB) Set an app state to make the connection more secure
   180  	authCodeURL := oauthConfig.AuthCodeURL("APP_STATE")
   181  	http.Redirect(w, r, authCodeURL, http.StatusSeeOther)
   182  }
   183  
   184  // HandleCallback is the callback handler for an OAuth2 login flow.
   185  func (a *DexAppClient) handleCallback(w http.ResponseWriter, r *http.Request) {
   186  	oauth2Config, err := a.oauth2Config(nil)
   187  	if err != nil {
   188  		http.Error(w, err.Error(), http.StatusInternalServerError)
   189  		return
   190  	}
   191  
   192  	if errMsg := r.FormValue("error"); errMsg != "" {
   193  		errorDesc := r.FormValue("error_description")
   194  		http.Error(w, html.EscapeString(errMsg)+": "+html.EscapeString(errorDesc), http.StatusBadRequest)
   195  		return
   196  	}
   197  
   198  	code := r.FormValue("code")
   199  	ctx := oidc.ClientContext(r.Context(), a.Client)
   200  	token, err := oauth2Config.Exchange(ctx, code)
   201  	if err != nil {
   202  		http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError)
   203  		return
   204  	}
   205  
   206  	idTokenRAW, ok := token.Extra("id_token").(string)
   207  	if !ok {
   208  		http.Error(w, "no id_token in token response", http.StatusInternalServerError)
   209  		return
   210  	}
   211  
   212  	idToken, err := ValidateOIDCToken(ctx, a.IssuerURL, idTokenRAW, a.ClientID)
   213  	if err != nil {
   214  		http.Error(w, "failed to verify the token", http.StatusInternalServerError)
   215  		return
   216  	}
   217  
   218  	var claims jwt.MapClaims
   219  	err = idToken.Claims(&claims)
   220  	if err != nil {
   221  		http.Error(w, err.Error(), http.StatusInternalServerError)
   222  		return
   223  	}
   224  
   225  	// Stores the oauth token into the cookie.
   226  	if idTokenRAW != "" {
   227  		expiration := time.Now().Add(time.Duration(expirationDays) * 24 * time.Hour)
   228  		//exhaustruct:ignore
   229  		cookie := http.Cookie{
   230  			Name:    dexOAUTHTokenName,
   231  			Value:   idTokenRAW,
   232  			Expires: expiration,
   233  			Path:    "/",
   234  		}
   235  		http.SetCookie(w, &cookie)
   236  	}
   237  	http.Redirect(w, r, a.BaseURL, http.StatusSeeOther)
   238  }
   239  
   240  func ValidateOIDCToken(ctx context.Context, issuerURL, rawToken string, allowedAudience string) (token *oidc.IDToken, err error) {
   241  	p, err := oidc.NewProvider(ctx, issuerURL)
   242  	if err != nil {
   243  		return nil, err
   244  	}
   245  
   246  	// Token must be verified against an allowed audience.
   247  	//exhaustruct:ignore
   248  	config := oidc.Config{ClientID: allowedAudience}
   249  	verifier := p.Verifier(&config)
   250  	idToken, err := verifier.Verify(ctx, rawToken)
   251  	if err != nil {
   252  		return nil, err
   253  	}
   254  
   255  	return idToken, nil
   256  }
   257  
   258  func (a *DexAppClient) oauth2Config(scopes []string) (c *oauth2.Config, err error) {
   259  	ctx := oidc.ClientContext(context.Background(), a.Client)
   260  	p, err := oidc.NewProvider(ctx, a.IssuerURL)
   261  	if err != nil {
   262  		return nil, err
   263  	}
   264  
   265  	return &oauth2.Config{
   266  		ClientID:     a.ClientID,
   267  		ClientSecret: a.ClientSecret,
   268  		Endpoint:     p.Endpoint(),
   269  		Scopes:       scopes,
   270  		RedirectURL:  a.RedirectURI,
   271  	}, nil
   272  }
   273  
   274  // Verifies if the user is authenticated.
   275  func VerifyToken(ctx context.Context, r *http.Request, clientID, baseURL string) (group string, err error) {
   276  	// Get the token cookie from the request
   277  	cookie, err := r.Cookie(dexOAUTHTokenName)
   278  	if err != nil {
   279  		return "", fmt.Errorf("%s token not found", dexOAUTHTokenName)
   280  	}
   281  	tokenString := cookie.Value
   282  
   283  	// Validates token audience and expiring date.
   284  	idToken, err := ValidateOIDCToken(ctx, baseURL+issuerPATH, tokenString, clientID)
   285  	if err != nil {
   286  		return "", fmt.Errorf("failed to verify token: %s", err)
   287  	}
   288  	// Extract token claims and verify the token is not expired.
   289  	claims := jwt.MapClaims{
   290  		"groups": []string{},
   291  	}
   292  	err = idToken.Claims(&claims)
   293  	if err != nil {
   294  		return "", fmt.Errorf("could not parse token claims")
   295  	}
   296  
   297  	// Convert the `groups` claim to an comma separated group string.
   298  	var groups string
   299  	if len(claims["groups"].([]interface{})) == 0 {
   300  		return "", fmt.Errorf("failed to verify token: no group defined")
   301  	}
   302  	for _, group := range claims["groups"].([]interface{}) {
   303  		groupName := strings.Trim(group.(string), "\"")
   304  		groups = groupName + "," + groups
   305  	}
   306  
   307  	// Returns the user object with the token information
   308  	return groups, nil
   309  }