diff --git a/pkg/keycloak/proxy/forwarding.go b/pkg/keycloak/proxy/forwarding.go index aae268a3..e73f76fe 100644 --- a/pkg/keycloak/proxy/forwarding.go +++ b/pkg/keycloak/proxy/forwarding.go @@ -16,12 +16,9 @@ limitations under the License. package proxy import ( - "context" "fmt" "net/http" - "github.com/Nerzal/gocloak/v12" - "github.com/gogatekeeper/gatekeeper/pkg/config/core" "github.com/gogatekeeper/gatekeeper/pkg/constant" "github.com/gogatekeeper/gatekeeper/pkg/utils" "go.uber.org/zap" @@ -114,105 +111,12 @@ func (r *OauthProxy) forwardProxyHandler() func(*http.Request, *http.Response) { var token string if r.Config.EnableUma { - ctx, cancel := context.WithTimeout( - context.Background(), - r.Config.OpenIDProviderTimeout, - ) - - defer cancel() - - matchingURI := true - - resourceParam := gocloak.GetResourceParams{ - URI: &req.URL.Path, - MatchingURI: &matchingURI, - } - - r.pat.m.Lock() - pat := r.pat.Token.AccessToken - r.pat.m.Unlock() - - resources, err := r.IdpClient.GetResourcesClient( - ctx, - pat, - r.Config.Realm, - resourceParam, - ) - - if err != nil { - r.Log.Error( - "problem getting resources for path", - zap.String("path", req.URL.Path), - zap.Error(err), - ) - return - } - - if len(resources) == 0 { - r.Log.Info( - "no resources for path", - zap.String("path", req.URL.Path), - ) - return - } - - resourceID := resources[0].ID - resourceScopes := make([]string, 0) - - if len(*resources[0].ResourceScopes) == 0 { - r.Log.Error( - "missing scopes for resource in IDP provider", - zap.String("resourceID", *resourceID), - ) - return - } - - for _, scope := range *resources[0].ResourceScopes { - resourceScopes = append(resourceScopes, *scope.Name) - } - - permissions := []gocloak.CreatePermissionTicketParams{ - { - ResourceID: resourceID, - ResourceScopes: &resourceScopes, - }, - } - - permTicket, err := r.IdpClient.CreatePermissionTicket( - ctx, - pat, - r.Config.Realm, - permissions, - ) - - if err != nil { - r.Log.Error( - "problem getting permission ticket for resourceId", - zap.String("resourceID", *resourceID), - zap.Error(err), - ) + tk := r.getRPT(req, resp) + if tk != nil { + token = tk.AccessToken + } else { return } - - grantType := core.GrantTypeUmaTicket - - rptOptions := gocloak.RequestingPartyTokenOptions{ - GrantType: &grantType, - Ticket: permTicket.Ticket, - } - - rpt, err := r.IdpClient.GetRequestingPartyToken(ctx, pat, r.Config.Realm, rptOptions) - - if err != nil { - r.Log.Error( - "problem getting RPT for resource (hint: do you have permissions assigned to resource?)", - zap.String("resourceID", *resourceID), - zap.Error(err), - ) - return - } - - token = rpt.AccessToken } else { r.pat.m.Lock() token = r.pat.Token.AccessToken diff --git a/pkg/keycloak/proxy/handlers.go b/pkg/keycloak/proxy/handlers.go index 180de010..46b80832 100644 --- a/pkg/keycloak/proxy/handlers.go +++ b/pkg/keycloak/proxy/handlers.go @@ -19,7 +19,6 @@ import ( "bytes" "context" "crypto/tls" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -32,7 +31,6 @@ import ( "strings" "time" - oidc3 "github.com/coreos/go-oidc/v3/oidc" "github.com/gogatekeeper/gatekeeper/pkg/apperrors" "github.com/gogatekeeper/gatekeeper/pkg/constant" "github.com/gogatekeeper/gatekeeper/pkg/encryption" @@ -170,7 +168,7 @@ func (r *OauthProxy) oauthAuthorizationHandler(wrt http.ResponseWriter, req *htt /* oauthCallbackHandler is responsible for handling the response from oauth service */ -//nolint:funlen,cyclop +//nolint:cyclop func (r *OauthProxy) oauthCallbackHandler(writer http.ResponseWriter, req *http.Request) { if r.Config.SkipTokenVerification { writer.WriteHeader(http.StatusNotAcceptable) @@ -178,293 +176,81 @@ func (r *OauthProxy) oauthCallbackHandler(writer http.ResponseWriter, req *http. } scope, assertOk := req.Context().Value(constant.ContextScopeName).(*RequestScope) - if !assertOk { - r.Log.Error( - "assertion failed", - ) + r.Log.Error("assertion failed") return } - // step: ensure we have a authorization code - code := req.URL.Query().Get("code") - - if code == "" { - r.accessError(writer, req) - return - } - - conf := r.newOAuth2Config(r.getRedirectionURL(writer, req)) - - var codeVerifier *http.Cookie - - if r.Config.EnablePKCE { - var err error - codeVerifier, err = req.Cookie(r.Config.CookiePKCEName) - if err != nil { - scope.Logger.Error("problem getting pkce cookie", zap.Error(err)) - r.accessForbidden(writer, req) - return - } - } - //nolint:contextcheck - resp, err := exchangeAuthenticationCode( - conf, - code, - codeVerifier, - r.Config.SkipOpenIDProviderTLSVerify, - ) - + accessToken, identityToken, refreshToken, err := r.getCodeFlowTokens(scope, writer, req) if err != nil { - scope.Logger.Error("unable to exchange code for access token", zap.Error(err)) - r.accessForbidden(writer, req) - return - } - - var rawToken string - // Flow: once we exchange the authorization code we parse the ID Token; we then check for an access token, - // if an access token is present and we can decode it, we use that as the session token, otherwise we default - // to the ID Token. - rawIDToken, assertOk := resp.Extra("id_token").(string) - - if !assertOk { - scope.Logger.Error("unable to obtain id token", zap.Error(err)) - r.accessForbidden(writer, req) return } - rawToken = rawIDToken - - verifier := r.Provider.Verifier(&oidc3.Config{ClientID: r.Config.ClientID}) - - var idToken *oidc3.IDToken - - ctx, cancel := context.WithTimeout( - context.Background(), - r.Config.OpenIDProviderTimeout, - ) - - defer cancel() + rawAccessToken := accessToken //nolint:contextcheck - idToken, err = verifier.Verify(ctx, rawIDToken) - - if err != nil { - scope.Logger.Error("unable to verify the id token", zap.Error(err)) - r.accessForbidden(writer, req) - return - } - - token, err := jwt.ParseSigned(rawIDToken) - + stdClaims, customClaims, err := r.verifyOIDCTokens(scope, accessToken, identityToken, writer, req) if err != nil { - scope.Logger.Error("unable to parse id token", zap.Error(err)) - r.accessForbidden(writer, req) return } - stdClaims := &jwt.Claims{} - // Extract custom claims - var customClaims struct { - Email string `json:"email"` - } - - err = token.UnsafeClaimsWithoutVerification(stdClaims, &customClaims) - - if err != nil { - scope.Logger.Error("unable to parse id token for claims", zap.Error(err)) - r.accessForbidden(writer, req) - return - } - - // check https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken - at_hash - // keycloak seems doesnt support yet at_hash - // https://stackoverflow.com/questions/60818373/configure-keycloak-to-include-an-at-hash-claim-in-the-id-token - if idToken.AccessTokenHash != "" { - err = idToken.VerifyAccessToken(resp.AccessToken) - - if err != nil { - scope.Logger.Error("unable to verify access token", zap.Error(err)) - r.accessForbidden(writer, req) - return - } - } - - accToken, err := jwt.ParseSigned(resp.AccessToken) - - if err == nil { - token = accToken - rawToken = resp.AccessToken - } else { - scope.Logger.Warn( - "unable to parse the access token, using id token only", - zap.Error(err), - ) - } - - stdClaims = &jwt.Claims{} - - err = token.UnsafeClaimsWithoutVerification(stdClaims, &customClaims) - - if err != nil { - scope.Logger.Error("unable to parse access token for claims", zap.Error(err)) - r.accessForbidden(writer, req) - return - } - - accessToken := rawToken - identityToken := rawIDToken - // step: are we encrypting the access token? if r.Config.EnableEncryptedToken || r.Config.ForceEncryptedCookie { - if accessToken, err = encryption.EncodeText(accessToken, r.Config.EncryptionKey); err != nil { - scope.Logger.Error("unable to encode the access token", zap.Error(err)) - writer.WriteHeader(http.StatusInternalServerError) + accessToken, err = r.encryptToken(scope, accessToken, r.Config.EncryptionKey, "access", writer, req) + if err != nil { return } - if identityToken, err = encryption.EncodeText(identityToken, r.Config.EncryptionKey); err != nil { - scope.Logger.Error("unable to encode the id token", zap.Error(err)) - writer.WriteHeader(http.StatusInternalServerError) + + identityToken, err = r.encryptToken(scope, identityToken, r.Config.EncryptionKey, "id", writer, req) + if err != nil { return } } - scope.Logger.Debug( - "issuing access token for user", - zap.String("access token", accessToken), - zap.String("email", customClaims.Email), - zap.String("sub", stdClaims.Subject), - zap.String("expires", stdClaims.Expiry.Time().Format(time.RFC3339)), - zap.String("duration", time.Until(stdClaims.Expiry.Time()).String()), - ) - - scope.Logger.Info( - "issuing access token for user", - zap.String("email", customClaims.Email), - zap.String("sub", stdClaims.Subject), - zap.String("expires", stdClaims.Expiry.Time().Format(time.RFC3339)), - zap.String("duration", time.Until(stdClaims.Expiry.Time()).String()), - ) - // @metric a token has been issued oauthTokensMetric.WithLabelValues("issued").Inc() + oidcTokensCookiesExp := time.Until(stdClaims.Expiry.Time()) // step: does the response have a refresh token and we do NOT ignore refresh tokens? - if r.Config.EnableRefreshTokens && resp.RefreshToken != "" { + if r.Config.EnableRefreshTokens && refreshToken != "" { var encrypted string - encrypted, err = encryption.EncodeText(resp.RefreshToken, r.Config.EncryptionKey) - + stdRefreshClaims, err := r.verifyRefreshToken(scope, refreshToken, writer, req) if err != nil { - scope.Logger.Error( - "failed to encrypt the refresh token", - zap.Error(err), - zap.String("sub", stdClaims.Subject), - zap.String("email", customClaims.Email), - ) - - writer.WriteHeader(http.StatusInternalServerError) return } - // drop in the access token - cookie expiration = access token - r.dropAccessTokenCookie( - req, - writer, - accessToken, - r.GetAccessCookieExpiration(resp.RefreshToken), - ) - - r.dropIDTokenCookie( - req, - writer, - identityToken, - r.GetAccessCookieExpiration(resp.RefreshToken), - ) - - var expiration time.Duration - // notes: not all idp refresh tokens are readable, google for example, so we attempt to decode into - // a jwt and if possible extract the expiration, else we default to 10 days - - refreshToken, err := jwt.ParseSigned(resp.RefreshToken) + oidcTokensCookiesExp = time.Until(stdRefreshClaims.Expiry.Time()) + encrypted, err = r.encryptToken(scope, refreshToken, r.Config.EncryptionKey, "refresh", writer, req) if err != nil { - scope.Logger.Error( - "failed to parse refresh token", - zap.Error(err), - zap.String("sub", stdClaims.Subject), - zap.String("email", customClaims.Email), - ) - - writer.WriteHeader(http.StatusInternalServerError) return } - stdRefreshClaims := &jwt.Claims{} - - err = refreshToken.UnsafeClaimsWithoutVerification(stdRefreshClaims) - - if err != nil { - expiration = 0 - } else { - expiration = time.Until(stdRefreshClaims.Expiry.Time()) - } - - switch r.useStore() { - case true: - if err = r.StoreRefreshToken(rawToken, encrypted, expiration); err != nil { - scope.Logger.Warn( + switch { + case r.useStore(): + if err = r.StoreRefreshToken(rawAccessToken, encrypted, oidcTokensCookiesExp); err != nil { + scope.Logger.Error( "failed to save the refresh token in the store", zap.Error(err), zap.String("sub", stdClaims.Subject), zap.String("email", customClaims.Email), ) + r.accessForbidden(writer, req) + return } default: - r.DropRefreshTokenCookie(req, writer, encrypted, expiration) + r.DropRefreshTokenCookie(req, writer, encrypted, oidcTokensCookiesExp) } - } else { - r.dropAccessTokenCookie( - req, - writer, - accessToken, - time.Until(stdClaims.Expiry.Time()), - ) - r.dropIDTokenCookie( - req, - writer, - identityToken, - time.Until(stdClaims.Expiry.Time()), - ) } + r.dropAccessTokenCookie(req, writer, accessToken, oidcTokensCookiesExp) + r.dropIDTokenCookie(req, writer, identityToken, oidcTokensCookiesExp) + // step: decode the request variable redirectURI := "/" - if req.URL.Query().Get("state") != "" { if encodedRequestURI, _ := req.Cookie(r.Config.CookieRequestURIName); encodedRequestURI != nil { - // some clients URL-escape padding characters - unescapedValue, err := url.PathUnescape(encodedRequestURI.Value) - - if err != nil { - scope.Logger.Warn( - "app did send a corrupted redirectURI in cookie: invalid url escaping", - zap.Error(err), - ) - } - // Since the value is passed with a cookie, we do not expect the client to use base64url (but the - // base64-encoded value may itself be url-encoded). - // This is safe for browsers using atob() but needs to be treated with care for nodeJS clients, - // which natively use base64url encoding, and url-escape padding '=' characters. - decoded, err := base64.StdEncoding.DecodeString(unescapedValue) - - if err != nil { - scope.Logger.Warn( - "app did send a corrupted redirectURI in cookie: invalid base64url encoding", - zap.Error(err), - zap.String("encoded_value", unescapedValue)) - } - - redirectURI = string(decoded) + redirectURI = r.getRequestURIFromCookie(scope, encodedRequestURI) } } @@ -574,15 +360,15 @@ func (r *OauthProxy) loginHandler(writer http.ResponseWriter, req *http.Request) if r.Config.EnableEncryptedToken || r.Config.ForceEncryptedCookie { if accessToken, err = encryption.EncodeText(accessToken, r.Config.EncryptionKey); err != nil { - scope.Logger.Error("unable to encode the access token", zap.Error(err)) + scope.Logger.Error("unable to encode access token", zap.Error(err)) return "unable to encode the access token", http.StatusInternalServerError, err } if idToken, err = encryption.EncodeText(idToken, r.Config.EncryptionKey); err != nil { - scope.Logger.Error("unable to encode the idToken token", zap.Error(err)) - return "unable to encode the idToken token", + scope.Logger.Error("unable to encode idToken token", zap.Error(err)) + return "unable to encode idToken token", http.StatusInternalServerError, err } @@ -593,8 +379,8 @@ func (r *OauthProxy) loginHandler(writer http.ResponseWriter, req *http.Request) refreshToken, err = encryption.EncodeText(token.RefreshToken, r.Config.EncryptionKey) if err != nil { - scope.Logger.Error("failed to encrypt the refresh token", zap.Error(err)) - return "failed to encrypt the refresh token", + scope.Logger.Error("failed to encrypt refresh token", zap.Error(err)) + return "failed to encrypt refresh token", http.StatusInternalServerError, err } diff --git a/pkg/keycloak/proxy/middleware.go b/pkg/keycloak/proxy/middleware.go index a959de7d..9f068885 100644 --- a/pkg/keycloak/proxy/middleware.go +++ b/pkg/keycloak/proxy/middleware.go @@ -370,7 +370,7 @@ func (r *OauthProxy) authenticationMiddleware() func(http.Handler) http.Handler if r.Config.EnableEncryptedToken || r.Config.ForceEncryptedCookie { if accessToken, err = encryption.EncodeText(accessToken, r.Config.EncryptionKey); err != nil { scope.Logger.Error( - "unable to encode the access token", zap.Error(err), + "unable to encode access token", zap.Error(err), zap.String("email", user.Email), zap.String("sub", user.ID), ) @@ -396,7 +396,7 @@ func (r *OauthProxy) authenticationMiddleware() func(http.Handler) http.Handler if err != nil { scope.Logger.Error( - "failed to encrypt the refresh token", + "failed to encrypt refresh token", zap.Error(err), zap.String("email", user.Email), zap.String("sub", user.ID), diff --git a/pkg/keycloak/proxy/misc.go b/pkg/keycloak/proxy/misc.go index 4a64f055..873b21d3 100644 --- a/pkg/keycloak/proxy/misc.go +++ b/pkg/keycloak/proxy/misc.go @@ -17,15 +17,22 @@ package proxy import ( "context" + "encoding/base64" "fmt" "net/http" + "net/url" + "os" "path" "strings" "time" "github.com/Nerzal/gocloak/v12" + oidc3 "github.com/coreos/go-oidc/v3/oidc" "github.com/gogatekeeper/gatekeeper/pkg/apperrors" + configcore "github.com/gogatekeeper/gatekeeper/pkg/config/core" "github.com/gogatekeeper/gatekeeper/pkg/constant" + "github.com/gogatekeeper/gatekeeper/pkg/encryption" + "github.com/gogatekeeper/gatekeeper/pkg/utils" "go.uber.org/zap" "gopkg.in/square/go-jose.v2/jwt" ) @@ -130,110 +137,16 @@ func (r *OauthProxy) redirectToURL(url string, wrt http.ResponseWriter, req *htt } // redirectToAuthorization redirects the user to authorization handler -func (r *OauthProxy) redirectToAuthorization(wrt http.ResponseWriter, req *http.Request) context.Context { //nolint:cyclop +func (r *OauthProxy) redirectToAuthorization(wrt http.ResponseWriter, req *http.Request) context.Context { if r.Config.NoRedirects && !r.Config.EnableUma { wrt.WriteHeader(http.StatusUnauthorized) return r.revokeProxy(wrt, req) } if r.Config.EnableUma { - ctx, cancel := context.WithTimeout( - context.Background(), - r.Config.OpenIDProviderTimeout, - ) - - defer cancel() - - matchingURI := true - - resourceParam := gocloak.GetResourceParams{ - URI: &req.URL.Path, - MatchingURI: &matchingURI, - } - - r.pat.m.Lock() - token := r.pat.Token.AccessToken - r.pat.m.Unlock() - - resources, err := r.IdpClient.GetResourcesClient( - ctx, - token, - r.Config.Realm, - resourceParam, - ) - - if err != nil { - r.Log.Error( - "problem getting resources for path", - zap.String("path", req.URL.Path), - zap.Error(err), - ) - wrt.WriteHeader(http.StatusUnauthorized) - return r.revokeProxy(wrt, req) - } - - if len(resources) == 0 { - r.Log.Info( - "no resources for path", - zap.String("path", req.URL.Path), - ) - wrt.WriteHeader(http.StatusUnauthorized) - return r.revokeProxy(wrt, req) - } - - resourceID := resources[0].ID - resourceScopes := make([]string, 0) - - if len(*resources[0].ResourceScopes) == 0 { - r.Log.Error( - "missingg scopes for resource in IDP provider", - zap.String("resourceID", *resourceID), - ) - wrt.WriteHeader(http.StatusUnauthorized) - return r.revokeProxy(wrt, req) - } - - for _, scope := range *resources[0].ResourceScopes { - resourceScopes = append(resourceScopes, *scope.Name) + if v := r.redirectToAuthorizationUMA(wrt, req); v != nil { + return v } - - permissions := []gocloak.CreatePermissionTicketParams{ - { - ResourceID: resourceID, - ResourceScopes: &resourceScopes, - }, - } - - permTicket, err := r.IdpClient.CreatePermissionTicket( - ctx, - token, - r.Config.Realm, - permissions, - ) - - if err != nil { - r.Log.Error( - "problem getting permission ticket for resourceId", - zap.String("resourceID", *resourceID), - zap.Error(err), - ) - wrt.WriteHeader(http.StatusUnauthorized) - return r.revokeProxy(wrt, req) - } - - permHeader := fmt.Sprintf( - `realm="%s", as_uri="%s", ticket="%s"`, - r.Config.Realm, - r.Config.DiscoveryURI.Host, - *permTicket.Ticket, - ) - - wrt.Header().Add( - "WWW-Authenticate", - permHeader, - ) - wrt.WriteHeader(http.StatusUnauthorized) - return r.revokeProxy(wrt, req) } // step: add a state referrer to the authorization page @@ -312,3 +225,556 @@ func (r *OauthProxy) GetAccessCookieExpiration(refresh string) time.Duration { return duration } + +func (r *OauthProxy) redirectToAuthorizationUMA(wrt http.ResponseWriter, req *http.Request) context.Context { + ctx, cancel := context.WithTimeout( + context.Background(), + r.Config.OpenIDProviderTimeout, + ) + + defer cancel() + + matchingURI := true + + resourceParam := gocloak.GetResourceParams{ + URI: &req.URL.Path, + MatchingURI: &matchingURI, + } + + r.pat.m.Lock() + token := r.pat.Token.AccessToken + r.pat.m.Unlock() + + resources, err := r.IdpClient.GetResourcesClient( + ctx, + token, + r.Config.Realm, + resourceParam, + ) + + if err != nil { + r.Log.Error( + "problem getting resources for path", + zap.String("path", req.URL.Path), + zap.Error(err), + ) + wrt.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(wrt, req) + } + + if len(resources) == 0 { + r.Log.Info( + "no resources for path", + zap.String("path", req.URL.Path), + ) + wrt.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(wrt, req) + } + + resourceID := resources[0].ID + resourceScopes := make([]string, 0) + + if len(*resources[0].ResourceScopes) == 0 { + r.Log.Error( + "missingg scopes for resource in IDP provider", + zap.String("resourceID", *resourceID), + ) + wrt.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(wrt, req) + } + + for _, scope := range *resources[0].ResourceScopes { + resourceScopes = append(resourceScopes, *scope.Name) + } + + if r.Config.NoRedirects { + permissions := []gocloak.CreatePermissionTicketParams{ + { + ResourceID: resourceID, + ResourceScopes: &resourceScopes, + }, + } + + permTicket, err := r.IdpClient.CreatePermissionTicket( + ctx, + token, + r.Config.Realm, + permissions, + ) + + if err != nil { + r.Log.Error( + "problem getting permission ticket for resourceId", + zap.String("resourceID", *resourceID), + zap.Error(err), + ) + wrt.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(wrt, req) + } + + permHeader := fmt.Sprintf( + `realm="%s", as_uri="%s", ticket="%s"`, + r.Config.Realm, + r.Config.DiscoveryURI.Host, + *permTicket.Ticket, + ) + + wrt.Header().Add("WWW-Authenticate", permHeader) + wrt.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(wrt, req) + } + + return nil +} + +//nolint:cyclop +func (r *OauthProxy) getPAT(done chan bool) { + retry := 0 + r.pat = &PAT{} + initialized := false + rConfig := *r.Config + clientID := rConfig.ClientID + clientSecret := rConfig.ClientSecret + realm := rConfig.Realm + timeout := rConfig.OpenIDProviderTimeout + patRetryCount := rConfig.PatRetryCount + patRetryInterval := rConfig.PatRetryInterval + grantType := configcore.GrantTypeClientCreds + + if rConfig.EnableForwarding && rConfig.ForwardingGrantType == configcore.GrantTypeUserCreds { + grantType = configcore.GrantTypeUserCreds + } + + for { + if retry > 0 { + r.Log.Info( + "retrying fetching PAT token", + zap.Int("retry", retry), + ) + } + + ctx, cancel := context.WithTimeout( + context.Background(), + timeout, + ) + + var token *gocloak.JWT + var err error + + switch grantType { + case configcore.GrantTypeClientCreds: + token, err = r.IdpClient.LoginClient( + ctx, + clientID, + clientSecret, + realm, + ) + case configcore.GrantTypeUserCreds: + token, err = r.IdpClient.Login( + ctx, + clientID, + clientSecret, + realm, + rConfig.ForwardingUsername, + rConfig.ForwardingPassword, + ) + default: + r.Log.Error( + "Chosen grant type is not supported", + zap.String("grant_type", grantType), + ) + os.Exit(11) + } + + if err != nil { + retry++ + r.Log.Error( + "problem getting PAT token", + + zap.Error(err), + ) + + if retry >= patRetryCount { + cancel() + os.Exit(10) + } + + <-time.After(patRetryInterval) + continue + } + + r.pat.m.Lock() + r.pat.Token = token + r.pat.m.Unlock() + + if !initialized { + done <- true + } + + initialized = true + + parsedToken, err := jwt.ParseSigned(token.AccessToken) + + if err != nil { + retry++ + r.Log.Error("failed to parse the access token", zap.Error(err)) + <-time.After(patRetryInterval) + continue + } + + stdClaims := &jwt.Claims{} + + err = parsedToken.UnsafeClaimsWithoutVerification(stdClaims) + + if err != nil { + retry++ + r.Log.Error("unable to parse access token for claims", zap.Error(err)) + <-time.After(patRetryInterval) + continue + } + + retry = 0 + expiration := stdClaims.Expiry.Time() + + refreshIn := utils.GetWithin(expiration, 0.85) + + r.Log.Info( + "waiting for expiration of access token", + zap.Float64("refresh_in", refreshIn.Seconds()), + ) + + <-time.After(refreshIn) + } +} + +// getRPT retrieves relaying party token +func (r *OauthProxy) getRPT(req *http.Request, resp *http.Response) *gocloak.JWT { + ctx, cancel := context.WithTimeout( + context.Background(), + r.Config.OpenIDProviderTimeout, + ) + + defer cancel() + + matchingURI := true + + resourceParam := gocloak.GetResourceParams{ + URI: &req.URL.Path, + MatchingURI: &matchingURI, + } + + r.pat.m.Lock() + pat := r.pat.Token.AccessToken + r.pat.m.Unlock() + + resources, err := r.IdpClient.GetResourcesClient( + ctx, + pat, + r.Config.Realm, + resourceParam, + ) + + if err != nil { + r.Log.Error( + "problem getting resources for path", + zap.String("path", req.URL.Path), + zap.Error(err), + ) + return nil + } + + if len(resources) == 0 { + r.Log.Info( + "no resources for path", + zap.String("path", req.URL.Path), + ) + return nil + } + + resourceID := resources[0].ID + resourceScopes := make([]string, 0) + + if len(*resources[0].ResourceScopes) == 0 { + r.Log.Error( + "missing scopes for resource in IDP provider", + zap.String("resourceID", *resourceID), + ) + return nil + } + + for _, scope := range *resources[0].ResourceScopes { + resourceScopes = append(resourceScopes, *scope.Name) + } + + permissions := []gocloak.CreatePermissionTicketParams{ + { + ResourceID: resourceID, + ResourceScopes: &resourceScopes, + }, + } + + permTicket, err := r.IdpClient.CreatePermissionTicket( + ctx, + pat, + r.Config.Realm, + permissions, + ) + + if err != nil { + r.Log.Error( + "problem getting permission ticket for resourceId", + zap.String("resourceID", *resourceID), + zap.Error(err), + ) + return nil + } + + grantType := configcore.GrantTypeUmaTicket + + rptOptions := gocloak.RequestingPartyTokenOptions{ + GrantType: &grantType, + Ticket: permTicket.Ticket, + } + + rpt, err := r.IdpClient.GetRequestingPartyToken(ctx, pat, r.Config.Realm, rptOptions) + + if err != nil { + r.Log.Error( + "problem getting RPT for resource (hint: do you have permissions assigned to resource?)", + zap.String("resourceID", *resourceID), + zap.Error(err), + ) + return nil + } + + return rpt +} + +func (r *OauthProxy) getCodeFlowTokens( + scope *RequestScope, + writer http.ResponseWriter, + req *http.Request, +) (string, string, string, error) { + // step: ensure we have a authorization code + code := req.URL.Query().Get("code") + + if code == "" { + r.accessError(writer, req) + return "", "", "", fmt.Errorf("missing auth code") + } + + conf := r.newOAuth2Config(r.getRedirectionURL(writer, req)) + + var codeVerifier *http.Cookie + + if r.Config.EnablePKCE { + var err error + codeVerifier, err = req.Cookie(r.Config.CookiePKCEName) + if err != nil { + scope.Logger.Error("problem getting pkce cookie", zap.Error(err)) + r.accessForbidden(writer, req) + return "", "", "", err + } + } + + resp, err := exchangeAuthenticationCode( + conf, + code, + codeVerifier, + r.Config.SkipOpenIDProviderTLSVerify, + ) + + if err != nil { + scope.Logger.Error("unable to exchange code for access token", zap.Error(err)) + r.accessForbidden(writer, req) + return "", "", "", err + } + + idToken, assertOk := resp.Extra("id_token").(string) + + if !assertOk { + scope.Logger.Error("unable to obtain id token", zap.Error(err)) + r.accessForbidden(writer, req) + return "", "", "", err + } + + return resp.AccessToken, idToken, resp.RefreshToken, nil +} + +func (r *OauthProxy) verifyOIDCTokens( + scope *RequestScope, + rawAccessToken string, + rawIDToken string, + writer http.ResponseWriter, + req *http.Request, +) (*jwt.Claims, *custClaims, error) { + var idToken *oidc3.IDToken + var err error + + verifier := r.Provider.Verifier(&oidc3.Config{ClientID: r.Config.ClientID}) + + ctx, cancel := context.WithTimeout( + context.Background(), + r.Config.OpenIDProviderTimeout, + ) + + defer cancel() + + idToken, err = verifier.Verify(ctx, rawIDToken) + + if err != nil { + scope.Logger.Error("unable to verify the id token", zap.Error(err)) + r.accessForbidden(writer, req) + return nil, nil, err + } + + token, err := jwt.ParseSigned(rawIDToken) + + if err != nil { + scope.Logger.Error("unable to parse id token", zap.Error(err)) + r.accessForbidden(writer, req) + return nil, nil, err + } + + stdClaims := &jwt.Claims{} + customClaims := &custClaims{} + + err = token.UnsafeClaimsWithoutVerification(stdClaims, customClaims) + + if err != nil { + scope.Logger.Error("unable to parse id token for claims", zap.Error(err)) + r.accessForbidden(writer, req) + return nil, nil, err + } + + // check https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken - at_hash + // keycloak seems doesnt support yet at_hash + // https://stackoverflow.com/questions/60818373/configure-keycloak-to-include-an-at-hash-claim-in-the-id-token + if idToken.AccessTokenHash != "" { + err = idToken.VerifyAccessToken(rawAccessToken) + + if err != nil { + scope.Logger.Error("unable to verify access token", zap.Error(err)) + r.accessForbidden(writer, req) + return nil, nil, err + } + } + + accToken, err := jwt.ParseSigned(rawAccessToken) + + if err != nil { + scope.Logger.Error( + "unable to parse the access token, using id token only", + ) + r.accessForbidden(writer, req) + return nil, nil, err + } + + token = accToken + stdClaims = &jwt.Claims{} + customClaims = &custClaims{} + + err = token.UnsafeClaimsWithoutVerification(stdClaims, customClaims) + + if err != nil { + scope.Logger.Error("unable to parse access token for claims", zap.Error(err)) + r.accessForbidden(writer, req) + return nil, nil, err + } + + scope.Logger.Debug( + "issuing access token for user", + zap.String("access token", rawAccessToken), + zap.String("email", customClaims.Email), + zap.String("sub", stdClaims.Subject), + zap.String("expires", stdClaims.Expiry.Time().Format(time.RFC3339)), + zap.String("duration", time.Until(stdClaims.Expiry.Time()).String()), + ) + + scope.Logger.Info( + "issuing access token for user", + zap.String("email", customClaims.Email), + zap.String("sub", stdClaims.Subject), + zap.String("expires", stdClaims.Expiry.Time().Format(time.RFC3339)), + zap.String("duration", time.Until(stdClaims.Expiry.Time()).String()), + ) + + return stdClaims, customClaims, nil +} + +func (r *OauthProxy) verifyRefreshToken( + scope *RequestScope, + rawRefreshToken string, + writer http.ResponseWriter, + req *http.Request, +) (*jwt.Claims, error) { + refreshToken, err := jwt.ParseSigned(rawRefreshToken) + + if err != nil { + scope.Logger.Error("failed to parse refresh token", zap.Error(err)) + writer.WriteHeader(http.StatusInternalServerError) + return nil, err + } + + stdRefreshClaims := &jwt.Claims{} + err = refreshToken.UnsafeClaimsWithoutVerification(stdRefreshClaims) + + if err != nil { + scope.Logger.Error("unable to parse refresh token for claims", zap.Error(err)) + r.accessForbidden(writer, req) + return nil, err + } + + return stdRefreshClaims, nil +} + +func (r *OauthProxy) encryptToken( + scope *RequestScope, + rawToken string, + encKey string, + tokenType string, + writer http.ResponseWriter, + req *http.Request, +) (string, error) { + var err error + var encrypted string + if encrypted, err = encryption.EncodeText(rawToken, encKey); err != nil { + scope.Logger.Error( + "failed to encrypt token", + zap.Error(err), + zap.String("type", tokenType), + ) + writer.WriteHeader(http.StatusInternalServerError) + return "", err + } + return encrypted, nil +} + +func (r *OauthProxy) getRequestURIFromCookie( + scope *RequestScope, + encodedRequestURI *http.Cookie, +) string { + // some clients URL-escape padding characters + unescapedValue, err := url.PathUnescape(encodedRequestURI.Value) + + if err != nil { + scope.Logger.Warn( + "app did send a corrupted redirectURI in cookie: invalid url escaping", + zap.Error(err), + ) + } + // Since the value is passed with a cookie, we do not expect the client to use base64url (but the + // base64-encoded value may itself be url-encoded). + // This is safe for browsers using atob() but needs to be treated with care for nodeJS clients, + // which natively use base64url encoding, and url-escape padding '=' characters. + decoded, err := base64.StdEncoding.DecodeString(unescapedValue) + + if err != nil { + scope.Logger.Warn( + "app did send a corrupted redirectURI in cookie: invalid base64url encoding", + zap.Error(err), + zap.String("encoded_value", unescapedValue)) + } + + return string(decoded) +} diff --git a/pkg/keycloak/proxy/oauth_proxy.go b/pkg/keycloak/proxy/oauth_proxy.go index 887c5c60..edf4b44a 100644 --- a/pkg/keycloak/proxy/oauth_proxy.go +++ b/pkg/keycloak/proxy/oauth_proxy.go @@ -71,27 +71,26 @@ type RequestScope struct { Logger *zap.Logger } +type RealmRoles struct { + Roles []string `json:"roles"` +} + +// Extract custom claims +type custClaims struct { + Email string `json:"email"` + PrefName string `json:"preferred_username"` + RealmAccess RealmRoles `json:"realm_access"` + Groups []string `json:"groups"` + ResourceAccess map[string]interface{} `json:"resource_access"` + FamilyName string `json:"family_name"` + GivenName string `json:"given_name"` + Username string `json:"username"` + Authorization authorization.Permissions `json:"authorization"` +} + // ExtractIdentity parse the jwt token and extracts the various elements is order to construct func ExtractIdentity(token *jwt.JSONWebToken) (*UserContext, error) { stdClaims := &jwt.Claims{} - - type RealmRoles struct { - Roles []string `json:"roles"` - } - - // Extract custom claims - type custClaims struct { - Email string `json:"email"` - PrefName string `json:"preferred_username"` - RealmAccess RealmRoles `json:"realm_access"` - Groups []string `json:"groups"` - ResourceAccess map[string]interface{} `json:"resource_access"` - FamilyName string `json:"family_name"` - GivenName string `json:"given_name"` - Username string `json:"username"` - Authorization authorization.Permissions `json:"authorization"` - } - customClaims := custClaims{} err := token.UnsafeClaimsWithoutVerification(stdClaims, &customClaims) diff --git a/pkg/keycloak/proxy/server.go b/pkg/keycloak/proxy/server.go index f28b088d..0aafbc8a 100644 --- a/pkg/keycloak/proxy/server.go +++ b/pkg/keycloak/proxy/server.go @@ -33,7 +33,6 @@ import ( "time" "go.uber.org/zap/zapcore" - "gopkg.in/square/go-jose.v2/jwt" "golang.org/x/crypto/acme/autocert" @@ -47,7 +46,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/gogatekeeper/gatekeeper/pkg/authorization" - configcore "github.com/gogatekeeper/gatekeeper/pkg/config/core" "github.com/gogatekeeper/gatekeeper/pkg/constant" "github.com/gogatekeeper/gatekeeper/pkg/encryption" "github.com/gogatekeeper/gatekeeper/pkg/keycloak/config" @@ -1133,123 +1131,3 @@ func (r *OauthProxy) NewOpenIDProvider() (*oidc3.Provider, *gocloak.GoCloak, err func (r *OauthProxy) Render(w io.Writer, name string, data interface{}) error { return r.templates.ExecuteTemplate(w, name, data) } - -//nolint:cyclop -func (r *OauthProxy) getPAT(done chan bool) { - retry := 0 - r.pat = &PAT{} - initialized := false - rConfig := *r.Config - clientID := rConfig.ClientID - clientSecret := rConfig.ClientSecret - realm := rConfig.Realm - timeout := rConfig.OpenIDProviderTimeout - patRetryCount := rConfig.PatRetryCount - patRetryInterval := rConfig.PatRetryInterval - grantType := configcore.GrantTypeClientCreds - - if rConfig.EnableForwarding && rConfig.ForwardingGrantType == configcore.GrantTypeUserCreds { - grantType = configcore.GrantTypeUserCreds - } - - for { - if retry > 0 { - r.Log.Info( - "retrying fetching PAT token", - zap.Int("retry", retry), - ) - } - - ctx, cancel := context.WithTimeout( - context.Background(), - timeout, - ) - - var token *gocloak.JWT - var err error - - switch grantType { - case configcore.GrantTypeClientCreds: - token, err = r.IdpClient.LoginClient( - ctx, - clientID, - clientSecret, - realm, - ) - case configcore.GrantTypeUserCreds: - token, err = r.IdpClient.Login( - ctx, - clientID, - clientSecret, - realm, - rConfig.ForwardingUsername, - rConfig.ForwardingPassword, - ) - default: - r.Log.Error( - "Chosen grant type is not supported", - zap.String("grant_type", grantType), - ) - os.Exit(11) - } - - if err != nil { - retry++ - r.Log.Error( - "problem getting PAT token", - - zap.Error(err), - ) - - if retry >= patRetryCount { - cancel() - os.Exit(10) - } - - <-time.After(patRetryInterval) - continue - } - - r.pat.m.Lock() - r.pat.Token = token - r.pat.m.Unlock() - - if !initialized { - done <- true - } - - initialized = true - - parsedToken, err := jwt.ParseSigned(token.AccessToken) - - if err != nil { - retry++ - r.Log.Error("failed to parse the access token", zap.Error(err)) - <-time.After(patRetryInterval) - continue - } - - stdClaims := &jwt.Claims{} - - err = parsedToken.UnsafeClaimsWithoutVerification(stdClaims) - - if err != nil { - retry++ - r.Log.Error("unable to parse access token for claims", zap.Error(err)) - <-time.After(patRetryInterval) - continue - } - - retry = 0 - expiration := stdClaims.Expiry.Time() - - refreshIn := utils.GetWithin(expiration, 0.85) - - r.Log.Info( - "waiting for expiration of access token", - zap.Float64("refresh_in", refreshIn.Seconds()), - ) - - <-time.After(refreshIn) - } -}