github.com/argoproj/argo-cd@v1.8.7/util/oidc/oidc.go (about) 1 package oidc 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "html" 7 "html/template" 8 "net" 9 "net/http" 10 "net/url" 11 "os" 12 "path" 13 "strings" 14 "time" 15 16 "github.com/argoproj/pkg/jwt/zjwt" 17 gooidc "github.com/coreos/go-oidc" 18 "github.com/dgrijalva/jwt-go/v4" 19 log "github.com/sirupsen/logrus" 20 "golang.org/x/oauth2" 21 22 "github.com/argoproj/argo-cd/common" 23 "github.com/argoproj/argo-cd/server/settings/oidc" 24 appstatecache "github.com/argoproj/argo-cd/util/cache/appstate" 25 "github.com/argoproj/argo-cd/util/dex" 26 httputil "github.com/argoproj/argo-cd/util/http" 27 "github.com/argoproj/argo-cd/util/rand" 28 "github.com/argoproj/argo-cd/util/settings" 29 ) 30 31 const ( 32 GrantTypeAuthorizationCode = "authorization_code" 33 GrantTypeImplicit = "implicit" 34 ResponseTypeCode = "code" 35 ) 36 37 // OIDCConfiguration holds a subset of interested fields from the OIDC configuration spec 38 type OIDCConfiguration struct { 39 Issuer string `json:"issuer"` 40 ScopesSupported []string `json:"scopes_supported"` 41 ResponseTypesSupported []string `json:"response_types_supported"` 42 GrantTypesSupported []string `json:"grant_types_supported,omitempty"` 43 } 44 45 type ClaimsRequest struct { 46 IDToken map[string]*oidc.Claim `json:"id_token"` 47 } 48 49 type OIDCState struct { 50 // ReturnURL is the URL in which to redirect a user back to after completing an OAuth2 login 51 ReturnURL string `json:"returnURL"` 52 } 53 54 type OIDCStateStorage interface { 55 GetOIDCState(key string) (*OIDCState, error) 56 SetOIDCState(key string, state *OIDCState) error 57 } 58 59 type ClientApp struct { 60 // OAuth2 client ID of this application (e.g. argo-cd) 61 clientID string 62 // OAuth2 client secret of this application 63 clientSecret string 64 // Callback URL for OAuth2 responses (e.g. https://argocd.example.com/auth/callback) 65 redirectURI string 66 // URL of the issuer (e.g. https://argocd.example.com/api/dex) 67 issuerURL string 68 // The URL endpoint at which the ArgoCD server is accessed. 69 baseHRef string 70 // client is the HTTP client which is used to query the IDp 71 client *http.Client 72 // secureCookie indicates if the cookie should be set with the Secure flag, meaning it should 73 // only ever be sent over HTTPS. This value is inferred by the scheme of the redirectURI. 74 secureCookie bool 75 // settings holds Argo CD settings 76 settings *settings.ArgoCDSettings 77 // provider is the OIDC provider 78 provider Provider 79 // cache holds temporary nonce tokens to which hold application state values 80 // See http://tools.ietf.org/html/rfc6749#section-10.12 for more info. 81 cache OIDCStateStorage 82 } 83 84 func GetScopesOrDefault(scopes []string) []string { 85 if len(scopes) == 0 { 86 return []string{"openid", "profile", "email", "groups"} 87 } 88 return scopes 89 } 90 91 // NewClientApp will register the Argo CD client app (either via Dex or external OIDC) and return an 92 // object which has HTTP handlers for handling the HTTP responses for login and callback 93 func NewClientApp(settings *settings.ArgoCDSettings, cache OIDCStateStorage, dexServerAddr, baseHRef string) (*ClientApp, error) { 94 redirectURL, err := settings.RedirectURL() 95 if err != nil { 96 return nil, err 97 } 98 a := ClientApp{ 99 clientID: settings.OAuth2ClientID(), 100 clientSecret: settings.OAuth2ClientSecret(), 101 redirectURI: redirectURL, 102 issuerURL: settings.IssuerURL(), 103 baseHRef: baseHRef, 104 cache: cache, 105 } 106 log.Infof("Creating client app (%s)", a.clientID) 107 u, err := url.Parse(settings.URL) 108 if err != nil { 109 return nil, fmt.Errorf("parse redirect-uri: %v", err) 110 } 111 tlsConfig := settings.TLSConfig() 112 if tlsConfig != nil { 113 tlsConfig.InsecureSkipVerify = true 114 } 115 a.client = &http.Client{ 116 Transport: &http.Transport{ 117 TLSClientConfig: tlsConfig, 118 Proxy: http.ProxyFromEnvironment, 119 Dial: (&net.Dialer{ 120 Timeout: 30 * time.Second, 121 KeepAlive: 30 * time.Second, 122 }).Dial, 123 TLSHandshakeTimeout: 10 * time.Second, 124 ExpectContinueTimeout: 1 * time.Second, 125 }, 126 } 127 if settings.DexConfig != "" && settings.OIDCConfigRAW == "" { 128 a.client.Transport = dex.NewDexRewriteURLRoundTripper(dexServerAddr, a.client.Transport) 129 } 130 if os.Getenv(common.EnvVarSSODebug) == "1" { 131 a.client.Transport = httputil.DebugTransport{T: a.client.Transport} 132 } 133 134 a.provider = NewOIDCProvider(a.issuerURL, a.client) 135 // NOTE: if we ever have replicas of Argo CD, this needs to switch to Redis cache 136 a.secureCookie = bool(u.Scheme == "https") 137 a.settings = settings 138 return &a, nil 139 } 140 141 func (a *ClientApp) oauth2Config(scopes []string) (*oauth2.Config, error) { 142 endpoint, err := a.provider.Endpoint() 143 if err != nil { 144 return nil, err 145 } 146 return &oauth2.Config{ 147 ClientID: a.clientID, 148 ClientSecret: a.clientSecret, 149 Endpoint: *endpoint, 150 Scopes: scopes, 151 RedirectURL: a.redirectURI, 152 }, nil 153 } 154 155 // generateAppState creates an app state nonce 156 func (a *ClientApp) generateAppState(returnURL string) string { 157 randStr := rand.RandString(10) 158 if returnURL == "" { 159 returnURL = a.baseHRef 160 } 161 err := a.cache.SetOIDCState(randStr, &OIDCState{ReturnURL: returnURL}) 162 if err != nil { 163 // This should never happen with the in-memory cache 164 log.Errorf("Failed to set app state: %v", err) 165 } 166 return randStr 167 } 168 169 func (a *ClientApp) verifyAppState(state string) (*OIDCState, error) { 170 res, err := a.cache.GetOIDCState(state) 171 if err != nil { 172 if err == appstatecache.ErrCacheMiss { 173 return nil, fmt.Errorf("unknown app state %s", state) 174 } else { 175 return nil, fmt.Errorf("failed to verify app state %s: %v", state, err) 176 } 177 } 178 179 _ = a.cache.SetOIDCState(state, nil) 180 return res, nil 181 } 182 183 // isValidRedirectURL checks whether the given redirectURL matches on of the 184 // allowed URLs to redirect to. 185 // 186 // In order to be considered valid,the protocol and host (including port) have 187 // to match and if allowed path is not "/", redirectURL's path must be within 188 // allowed URL's path. 189 func isValidRedirectURL(redirectURL string, allowedURLs []string) bool { 190 if redirectURL == "" { 191 return true 192 } 193 r, err := url.Parse(redirectURL) 194 if err != nil { 195 return false 196 } 197 // We consider empty path the same as "/" for redirect URL 198 if r.Path == "" { 199 r.Path = "/" 200 } 201 // Prevent CLRF in the redirectURL 202 if strings.ContainsAny(r.Path, "\r\n") { 203 return false 204 } 205 for _, baseURL := range allowedURLs { 206 b, err := url.Parse(baseURL) 207 if err != nil { 208 continue 209 } 210 // We consider empty path the same as "/" for allowed URL 211 if b.Path == "" { 212 b.Path = "/" 213 } 214 // scheme and host are mandatory to match. 215 if b.Scheme == r.Scheme && b.Host == r.Host { 216 // If path of redirectURL and allowedURL match, redirectURL is allowed 217 //if b.Path == r.Path { 218 // return true 219 //} 220 // If path of redirectURL is within allowed URL's path, redirectURL is allowed 221 if strings.HasPrefix(path.Clean(r.Path), b.Path) { 222 return true 223 } 224 } 225 } 226 // No match - redirect URL is not allowed 227 return false 228 } 229 230 // HandleLogin formulates the proper OAuth2 URL (auth code or implicit) and redirects the user to 231 // the IDp login & consent page 232 func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) { 233 oidcConf, err := a.provider.ParseConfig() 234 if err != nil { 235 http.Error(w, err.Error(), http.StatusInternalServerError) 236 return 237 } 238 scopes := make([]string, 0) 239 var opts []oauth2.AuthCodeOption 240 if config := a.settings.OIDCConfig(); config != nil { 241 scopes = config.RequestedScopes 242 opts = AppendClaimsAuthenticationRequestParameter(opts, config.RequestedIDTokenClaims) 243 } 244 oauth2Config, err := a.oauth2Config(GetScopesOrDefault(scopes)) 245 if err != nil { 246 http.Error(w, err.Error(), http.StatusInternalServerError) 247 return 248 } 249 returnURL := r.FormValue("return_url") 250 // Check if return_url is valid, otherwise abort processing (see #2707) 251 if !isValidRedirectURL(returnURL, []string{a.settings.URL}) { 252 http.Error(w, "Invalid return_url", http.StatusBadRequest) 253 return 254 } 255 stateNonce := a.generateAppState(returnURL) 256 grantType := InferGrantType(oidcConf) 257 var url string 258 switch grantType { 259 case GrantTypeAuthorizationCode: 260 url = oauth2Config.AuthCodeURL(stateNonce, opts...) 261 case GrantTypeImplicit: 262 url = ImplicitFlowURL(oauth2Config, stateNonce, opts...) 263 default: 264 http.Error(w, fmt.Sprintf("Unsupported grant type: %v", grantType), http.StatusInternalServerError) 265 return 266 } 267 log.Infof("Performing %s flow login: %s", grantType, url) 268 http.Redirect(w, r, url, http.StatusSeeOther) 269 } 270 271 // HandleCallback is the callback handler for an OAuth2 login flow 272 func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) { 273 oauth2Config, err := a.oauth2Config(nil) 274 if err != nil { 275 http.Error(w, err.Error(), http.StatusInternalServerError) 276 return 277 } 278 log.Infof("Callback: %s", r.URL) 279 if errMsg := r.FormValue("error"); errMsg != "" { 280 errorDesc := r.FormValue("error_description") 281 http.Error(w, html.EscapeString(errMsg)+": "+html.EscapeString(errorDesc), http.StatusBadRequest) 282 return 283 } 284 code := r.FormValue("code") 285 state := r.FormValue("state") 286 if code == "" { 287 // If code was not given, it implies implicit flow 288 a.handleImplicitFlow(w, state) 289 return 290 } 291 appState, err := a.verifyAppState(state) 292 if err != nil { 293 http.Error(w, err.Error(), http.StatusBadRequest) 294 return 295 } 296 ctx := gooidc.ClientContext(r.Context(), a.client) 297 token, err := oauth2Config.Exchange(ctx, code) 298 if err != nil { 299 http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) 300 return 301 } 302 idTokenRAW, ok := token.Extra("id_token").(string) 303 if !ok { 304 http.Error(w, "no id_token in token response", http.StatusInternalServerError) 305 return 306 } 307 idToken, err := a.provider.Verify(a.clientID, idTokenRAW) 308 if err != nil { 309 http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusInternalServerError) 310 return 311 } 312 path := "/" 313 if a.baseHRef != "" { 314 path = strings.TrimRight(strings.TrimLeft(a.baseHRef, "/"), "/") 315 } 316 cookiePath := fmt.Sprintf("path=/%s", path) 317 flags := []string{cookiePath, "SameSite=lax", "httpOnly"} 318 if a.secureCookie { 319 flags = append(flags, "Secure") 320 } 321 var claims jwt.MapClaims 322 err = idToken.Claims(&claims) 323 if err != nil { 324 http.Error(w, err.Error(), http.StatusInternalServerError) 325 return 326 } 327 if idTokenRAW != "" { 328 idTokenRAW, err = zjwt.ZJWT(idTokenRAW) 329 if err != nil { 330 http.Error(w, err.Error(), http.StatusInternalServerError) 331 return 332 } 333 cookie, err := httputil.MakeCookieMetadata(common.AuthCookieName, idTokenRAW, flags...) 334 if err != nil { 335 claimsJSON, _ := json.Marshal(claims) 336 http.Error(w, fmt.Sprintf("claims=%s, err=%v", claimsJSON, err), http.StatusInternalServerError) 337 return 338 } 339 w.Header().Set("Set-Cookie", cookie) 340 } 341 342 claimsJSON, _ := json.Marshal(claims) 343 log.Infof("Web login successful. Claims: %s", claimsJSON) 344 if os.Getenv(common.EnvVarSSODebug) == "1" { 345 claimsJSON, _ := json.MarshalIndent(claims, "", " ") 346 renderToken(w, a.redirectURI, idTokenRAW, token.RefreshToken, claimsJSON) 347 } else { 348 http.Redirect(w, r, appState.ReturnURL, http.StatusSeeOther) 349 } 350 } 351 352 var implicitFlowTmpl = template.Must(template.New("implicit.html").Parse(`<script> 353 var hash = window.location.hash.substr(1); 354 var result = hash.split('&').reduce(function (result, item) { 355 var parts = item.split('='); 356 result[parts[0]] = parts[1]; 357 return result; 358 }, {}); 359 var idToken = result['id_token']; 360 var state = result['state']; 361 var returnURL = "{{ .ReturnURL }}"; 362 if (state != "" && returnURL == "") { 363 window.location.href = window.location.href.split("#")[0] + "?state=" + result['state'] + window.location.hash; 364 } else if (returnURL != "") { 365 document.cookie = "{{ .CookieName }}=" + idToken + "; path=/"; 366 window.location.href = returnURL; 367 } 368 </script>`)) 369 370 // handleImplicitFlow completes an implicit OAuth2 flow. The id_token and state will be contained 371 // in the URL fragment. The javascript client first redirects to the callback URL, supplying the 372 // state nonce for verification, as well as looking up the return URL. Once verified, the client 373 // stores the id_token from the fragment as a cookie. Finally it performs the final redirect back to 374 // the return URL. 375 func (a *ClientApp) handleImplicitFlow(w http.ResponseWriter, state string) { 376 type implicitFlowValues struct { 377 CookieName string 378 ReturnURL string 379 } 380 vals := implicitFlowValues{ 381 CookieName: common.AuthCookieName, 382 } 383 if state != "" { 384 appState, err := a.verifyAppState(state) 385 if err != nil { 386 http.Error(w, err.Error(), http.StatusBadRequest) 387 return 388 } 389 vals.ReturnURL = appState.ReturnURL 390 } 391 renderTemplate(w, implicitFlowTmpl, vals) 392 } 393 394 // ImplicitFlowURL is an adaptation of oauth2.Config::AuthCodeURL() which returns a URL 395 // appropriate for an OAuth2 implicit login flow (as opposed to authorization code flow). 396 func ImplicitFlowURL(c *oauth2.Config, state string, opts ...oauth2.AuthCodeOption) string { 397 opts = append(opts, oauth2.SetAuthURLParam("response_type", "id_token")) 398 opts = append(opts, oauth2.SetAuthURLParam("nonce", rand.RandString(10))) 399 return c.AuthCodeURL(state, opts...) 400 } 401 402 // OfflineAccess returns whether or not 'offline_access' is a supported scope 403 func OfflineAccess(scopes []string) bool { 404 if len(scopes) == 0 { 405 // scopes_supported is a "RECOMMENDED" discovery claim, not a required 406 // one. If missing, assume that the provider follows the spec and has 407 // an "offline_access" scope. 408 return true 409 } 410 // See if scopes_supported has the "offline_access" scope. 411 for _, scope := range scopes { 412 if scope == gooidc.ScopeOfflineAccess { 413 return true 414 } 415 } 416 return false 417 } 418 419 // InferGrantType infers the proper grant flow depending on the OAuth2 client config and OIDC configuration. 420 // Returns either: "authorization_code" or "implicit" 421 func InferGrantType(oidcConf *OIDCConfiguration) string { 422 // Check the supported response types. If the list contains the response type 'code', 423 // then grant type is 'authorization_code'. This is preferred over the implicit 424 // grant type since refresh tokens cannot be issued that way. 425 for _, supportedType := range oidcConf.ResponseTypesSupported { 426 if supportedType == ResponseTypeCode { 427 return GrantTypeAuthorizationCode 428 } 429 } 430 431 // Assume implicit otherwise 432 return GrantTypeImplicit 433 } 434 435 // AppendClaimsAuthenticationRequestParameter appends a OIDC claims authentication request parameter 436 // to `opts` with the `requestedClaims` 437 func AppendClaimsAuthenticationRequestParameter(opts []oauth2.AuthCodeOption, requestedClaims map[string]*oidc.Claim) []oauth2.AuthCodeOption { 438 if len(requestedClaims) == 0 { 439 return opts 440 } 441 log.Infof("RequestedClaims: %s\n", requestedClaims) 442 claimsRequestParameter, err := createClaimsAuthenticationRequestParameter(requestedClaims) 443 if err != nil { 444 log.Errorf("Failed to create OIDC claims authentication request parameter from config: %s", err) 445 return opts 446 } 447 return append(opts, claimsRequestParameter) 448 } 449 450 func createClaimsAuthenticationRequestParameter(requestedClaims map[string]*oidc.Claim) (oauth2.AuthCodeOption, error) { 451 claimsRequest := ClaimsRequest{IDToken: requestedClaims} 452 claimsRequestRAW, err := json.Marshal(claimsRequest) 453 if err != nil { 454 return nil, err 455 } 456 return oauth2.SetAuthURLParam("claims", string(claimsRequestRAW)), nil 457 }