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 }