github.com/minio/minio@v0.0.0-20240328213742-3f72439b8a27/internal/config/identity/openid/jwt.go (about) 1 // Copyright (c) 2015-2022 MinIO, Inc. 2 // 3 // This file is part of MinIO Object Storage stack 4 // 5 // This program is free software: you can redistribute it and/or modify 6 // it under the terms of the GNU Affero General Public License as published by 7 // the Free Software Foundation, either version 3 of the License, or 8 // (at your option) any later version. 9 // 10 // This program is distributed in the hope that it will be useful 11 // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 // GNU Affero General Public License for more details. 14 // 15 // You should have received a copy of the GNU Affero General Public License 16 // along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18 package openid 19 20 import ( 21 "context" 22 "encoding/json" 23 "errors" 24 "fmt" 25 "io" 26 "net/http" 27 "sync" 28 "time" 29 30 jwtgo "github.com/golang-jwt/jwt/v4" 31 "github.com/minio/minio/internal/arn" 32 "github.com/minio/minio/internal/auth" 33 xnet "github.com/minio/pkg/v2/net" 34 "github.com/minio/pkg/v2/policy" 35 ) 36 37 type publicKeys struct { 38 *sync.RWMutex 39 40 // map of kid to public key 41 pkMap map[string]interface{} 42 } 43 44 func (pk *publicKeys) parseAndAdd(b io.Reader) error { 45 var jwk JWKS 46 err := json.NewDecoder(b).Decode(&jwk) 47 if err != nil { 48 return err 49 } 50 51 for _, key := range jwk.Keys { 52 pkey, err := key.DecodePublicKey() 53 if err != nil { 54 return err 55 } 56 pk.add(key.Kid, pkey) 57 } 58 59 return nil 60 } 61 62 func (pk *publicKeys) add(keyID string, key interface{}) { 63 pk.Lock() 64 defer pk.Unlock() 65 66 pk.pkMap[keyID] = key 67 } 68 69 func (pk *publicKeys) get(kid string) interface{} { 70 pk.RLock() 71 defer pk.RUnlock() 72 return pk.pkMap[kid] 73 } 74 75 // PopulatePublicKey - populates a new publickey from the JWKS URL. 76 func (r *Config) PopulatePublicKey(arn arn.ARN) error { 77 pCfg := r.arnProviderCfgsMap[arn] 78 if pCfg.JWKS.URL == nil || pCfg.JWKS.URL.String() == "" { 79 return nil 80 } 81 82 // Add client secret for the client ID for HMAC based signature. 83 r.pubKeys.add(pCfg.ClientID, []byte(pCfg.ClientSecret)) 84 85 client := &http.Client{ 86 Transport: r.transport, 87 } 88 89 resp, err := client.Get(pCfg.JWKS.URL.String()) 90 if err != nil { 91 return err 92 } 93 defer r.closeRespFn(resp.Body) 94 if resp.StatusCode != http.StatusOK { 95 return errors.New(resp.Status) 96 } 97 98 return r.pubKeys.parseAndAdd(resp.Body) 99 } 100 101 // ErrTokenExpired - error token expired 102 var ( 103 ErrTokenExpired = errors.New("token expired") 104 ) 105 106 func updateClaimsExpiry(dsecs string, claims map[string]interface{}) error { 107 expStr := claims["exp"] 108 if expStr == "" { 109 return ErrTokenExpired 110 } 111 112 // No custom duration requested, the claims can be used as is. 113 if dsecs == "" { 114 return nil 115 } 116 117 if _, err := auth.ExpToInt64(expStr); err != nil { 118 return err 119 } 120 121 defaultExpiryDuration, err := GetDefaultExpiration(dsecs) 122 if err != nil { 123 return err 124 } 125 126 claims["exp"] = time.Now().UTC().Add(defaultExpiryDuration).Unix() // update with new expiry. 127 return nil 128 } 129 130 const ( 131 audClaim = "aud" 132 azpClaim = "azp" 133 ) 134 135 // Validate - validates the id_token. 136 func (r *Config) Validate(ctx context.Context, arn arn.ARN, token, accessToken, dsecs string, claims jwtgo.MapClaims) error { 137 jp := new(jwtgo.Parser) 138 jp.ValidMethods = []string{ 139 "RS256", "RS384", "RS512", 140 "ES256", "ES384", "ES512", 141 "HS256", "HS384", "HS512", 142 "RS3256", "RS3384", "RS3512", 143 "ES3256", "ES3384", "ES3512", 144 } 145 146 keyFuncCallback := func(jwtToken *jwtgo.Token) (interface{}, error) { 147 kid, ok := jwtToken.Header["kid"].(string) 148 if !ok { 149 return nil, fmt.Errorf("Invalid kid value %v", jwtToken.Header["kid"]) 150 } 151 return r.pubKeys.get(kid), nil 152 } 153 154 pCfg, ok := r.arnProviderCfgsMap[arn] 155 if !ok { 156 return fmt.Errorf("Role %s does not exist", arn) 157 } 158 159 jwtToken, err := jp.ParseWithClaims(token, &claims, keyFuncCallback) 160 if err != nil { 161 // Re-populate the public key in-case the JWKS 162 // pubkeys are refreshed 163 if err = r.PopulatePublicKey(arn); err != nil { 164 return err 165 } 166 jwtToken, err = jwtgo.ParseWithClaims(token, &claims, keyFuncCallback) 167 if err != nil { 168 return err 169 } 170 } 171 172 if !jwtToken.Valid { 173 return ErrTokenExpired 174 } 175 176 if err = updateClaimsExpiry(dsecs, claims); err != nil { 177 return err 178 } 179 180 if err = r.updateUserinfoClaims(ctx, arn, accessToken, claims); err != nil { 181 return err 182 } 183 184 // Validate that matching clientID appears in the aud or azp claims. 185 186 // REQUIRED. Audience(s) that this ID Token is intended for. 187 // It MUST contain the OAuth 2.0 client_id of the Relying Party 188 // as an audience value. It MAY also contain identifiers for 189 // other audiences. In the general case, the aud value is an 190 // array of case sensitive strings. In the common special case 191 // when there is one audience, the aud value MAY be a single 192 // case sensitive 193 audValues, ok := policy.GetValuesFromClaims(claims, audClaim) 194 if !ok { 195 return errors.New("STS JWT Token has `aud` claim invalid, `aud` must match configured OpenID Client ID") 196 } 197 if !audValues.Contains(pCfg.ClientID) { 198 // if audience claims is missing, look for "azp" claims. 199 // OPTIONAL. Authorized party - the party to which the ID 200 // Token was issued. If present, it MUST contain the OAuth 201 // 2.0 Client ID of this party. This Claim is only needed 202 // when the ID Token has a single audience value and that 203 // audience is different than the authorized party. It MAY 204 // be included even when the authorized party is the same 205 // as the sole audience. The azp value is a case sensitive 206 // string containing a StringOrURI value 207 azpValues, ok := policy.GetValuesFromClaims(claims, azpClaim) 208 if !ok { 209 return errors.New("STS JWT Token has `azp` claim invalid, `azp` must match configured OpenID Client ID") 210 } 211 if !azpValues.Contains(pCfg.ClientID) { 212 return errors.New("STS JWT Token has `azp` claim invalid, `azp` must match configured OpenID Client ID") 213 } 214 } 215 216 return nil 217 } 218 219 func (r *Config) updateUserinfoClaims(ctx context.Context, arn arn.ARN, accessToken string, claims map[string]interface{}) error { 220 pCfg, ok := r.arnProviderCfgsMap[arn] 221 // If claim user info is enabled, get claims from userInfo 222 // and overwrite them with the claims from JWT. 223 if ok && pCfg.ClaimUserinfo { 224 if accessToken == "" { 225 return errors.New("access_token is mandatory if user_info claim is enabled") 226 } 227 uclaims, err := pCfg.UserInfo(ctx, accessToken, r.transport) 228 if err != nil { 229 return err 230 } 231 for k, v := range uclaims { 232 if _, ok := claims[k]; !ok { // only add to claims not update it. 233 claims[k] = v 234 } 235 } 236 } 237 return nil 238 } 239 240 // DiscoveryDoc - parses the output from openid-configuration 241 // for example https://accounts.google.com/.well-known/openid-configuration 242 type DiscoveryDoc struct { 243 Issuer string `json:"issuer,omitempty"` 244 AuthEndpoint string `json:"authorization_endpoint,omitempty"` 245 TokenEndpoint string `json:"token_endpoint,omitempty"` 246 EndSessionEndpoint string `json:"end_session_endpoint,omitempty"` 247 UserInfoEndpoint string `json:"userinfo_endpoint,omitempty"` 248 RevocationEndpoint string `json:"revocation_endpoint,omitempty"` 249 JwksURI string `json:"jwks_uri,omitempty"` 250 ResponseTypesSupported []string `json:"response_types_supported,omitempty"` 251 SubjectTypesSupported []string `json:"subject_types_supported,omitempty"` 252 IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"` 253 ScopesSupported []string `json:"scopes_supported,omitempty"` 254 TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported,omitempty"` 255 ClaimsSupported []string `json:"claims_supported,omitempty"` 256 CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` 257 } 258 259 func parseDiscoveryDoc(u *xnet.URL, transport http.RoundTripper, closeRespFn func(io.ReadCloser)) (DiscoveryDoc, error) { 260 d := DiscoveryDoc{} 261 req, err := http.NewRequest(http.MethodGet, u.String(), nil) 262 if err != nil { 263 return d, err 264 } 265 clnt := http.Client{ 266 Transport: transport, 267 } 268 resp, err := clnt.Do(req) 269 if err != nil { 270 return d, err 271 } 272 defer closeRespFn(resp.Body) 273 if resp.StatusCode != http.StatusOK { 274 return d, fmt.Errorf("unexpected error returned by %s : status(%s)", u, resp.Status) 275 } 276 dec := json.NewDecoder(resp.Body) 277 if err = dec.Decode(&d); err != nil { 278 return d, err 279 } 280 return d, nil 281 }