diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b9c982cb..514df40b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: # Run Go benchmarks - name: Benchmark run: | - go test -bench=. -benchmem + go test -run=^$ -bench=. -benchmem # Sends code coverage report to Coveralls - name: Coveralls env: diff --git a/.gitignore b/.gitignore index f4434dc3..5ddd7c79 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ _testmain.go *.test *.prof __debug_bin +.env \ No newline at end of file diff --git a/common_test.go b/common_test.go index e7c0d6ee..fdfa1ee0 100644 --- a/common_test.go +++ b/common_test.go @@ -80,6 +80,7 @@ type DefaultTestTokenClaims struct { Item1 []string `json:"item1"` Item2 []string `json:"item2"` Item3 []string `json:"item3"` + Authorization Permissions `json:"authorization"` } var defTestTokenClaims = DefaultTestTokenClaims{ @@ -111,6 +112,15 @@ var defTestTokenClaims = DefaultTestTokenClaims{ Item1: []string{"default"}, Item2: []string{"default"}, Item3: []string{"default"}, + Authorization: Permissions{ + Permissions: []Permission{ + { + Scopes: []string{"test"}, + ResourceID: "6ef1b62e-0fd4-47f2-81fc-eead97a01c22", + ResourceName: "test-resource", + }, + }, + }, } const ( @@ -140,6 +150,7 @@ type fakeRequest struct { SkipIssuerCheck bool RequestCA string TokenClaims map[string]interface{} + TokenAuthorization *Permissions URI string URL string Username string @@ -183,6 +194,12 @@ func newFakeProxy(c *Config, authConfig *fakeAuthConfig) *fakeProxy { c.DiscoveryURL = auth.getLocation() c.Verbose = true c.DisableAllLogging = true + err := c.update() + + if err != nil { + panic("failed to create fake proxy service, error: " + err.Error()) + } + proxy, err := newProxy(c) if err != nil { @@ -332,6 +349,10 @@ func (f *fakeProxy) RunTests(t *testing.T, requests []fakeRequest) { token.setExpiration(time.Now().Add(c.Expires)) } + if c.TokenAuthorization != nil { + token.claims.Authorization = *c.TokenAuthorization + } + if c.NotSigned { authToken, err := token.getUnsignedToken() assert.NoError(t, err) @@ -666,6 +687,11 @@ func newTestProxyService(config *Config) (*oauthProxy, *fakeAuthServer, string) config.RevocationEndpoint = auth.getRevocationURL() config.Verbose = false config.EnableLogging = false + err := config.update() + + if err != nil { + panic("failed to create proxy service, error: " + err.Error()) + } proxy, err := newProxy(config) if err != nil { @@ -1095,6 +1121,9 @@ func newFakeAuthServer(config *fakeAuthConfig) *fakeAuthServer { r.Post("/auth/realms/hod-test/protocol/openid-connect/logout", service.logoutHandler) r.Post("/auth/realms/hod-test/protocol/openid-connect/revoke", service.revocationHandler) r.Post("/auth/realms/hod-test/protocol/openid-connect/token", service.tokenHandler) + r.Get("/auth/realms/hod-test/authz/protection/resource_set", service.ResourcesHandler) + r.Get("/auth/realms/hod-test/authz/protection/resource_set/{id}", service.ResourceHandler) + r.Post("/auth/realms/hod-test/authz/protection/permission", service.PermissionTicketHandler) if config.EnableTLS { service.server = httptest.NewTLSServer(r) @@ -1280,8 +1309,14 @@ func (r *fakeAuthServer) tokenHandler(w http.ResponseWriter, req *http.Request) clientSecret := req.FormValue("client_secret") if clientID == "" || clientSecret == "" { - w.WriteHeader(http.StatusBadRequest) - return + u, p, ok := req.BasicAuth() + clientID = u + clientSecret = p + + if clientID == "" || clientSecret == "" || !ok { + w.WriteHeader(http.StatusBadRequest) + return + } } if clientID == validUsername && clientSecret == validPassword { @@ -1296,7 +1331,7 @@ func (r *fakeAuthServer) tokenHandler(w http.ResponseWriter, req *http.Request) renderJSON(http.StatusUnauthorized, w, req, map[string]string{ "error": "invalid_grant", - "error_description": "invalid user credentials", + "error_description": "invalid client credentials", }) case GrantTypeRefreshToken: oldRefreshToken, err := jwt.ParseSigned(req.FormValue("refresh_token")) @@ -1354,6 +1389,65 @@ func (r *fakeAuthServer) tokenHandler(w http.ResponseWriter, req *http.Request) } } +func (r *fakeAuthServer) ResourcesHandler(w http.ResponseWriter, req *http.Request) { + response := []string{"6ef1b62e-0fd4-47f2-81fc-eead97a01c22"} + renderJSON(http.StatusOK, w, req, response) +} + +func (r *fakeAuthServer) ResourceHandler(w http.ResponseWriter, req *http.Request) { + type Resource struct { + Name string `json:"name"` + Type string `json:"type"` + Owner struct{ ID string } `json:"owner"` + OwnerManagedAccess bool `json:"ownerManagedAccess"` + Attributes struct{} `json:"attributes"` + ID string `json:"_id"` + URIS []string `json:"uris"` + ResourceScopes []struct { + Name string `json:"name"` + } `json:"resource_scopes"` + Scopes []struct { + Name string `json:"name"` + } `json:"scopes"` + } + + response := Resource{ + Name: "Default Resource", + Type: "urn:test-client:resources:default", + Owner: struct{ ID string }{ID: "6ef1b62e-0fd4-47f2-81fc-eead97a01c22"}, + OwnerManagedAccess: false, + Attributes: struct{}{}, + ID: "6ef1b62e-0fd4-47f2-81fc-eead97a01c22", + URIS: []string{"/*"}, + ResourceScopes: []struct { + Name string `json:"name"` + }{{Name: "test"}}, + Scopes: []struct { + Name string `json:"name"` + }{{Name: "test"}}, + } + renderJSON(http.StatusOK, w, req, response) +} + +func (r *fakeAuthServer) PermissionTicketHandler(w http.ResponseWriter, req *http.Request) { + token := newTestToken(r.getLocation()) + acc, err := token.getToken() + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + type Ticket struct { + Ticket string `json:"ticket"` + } + + response := Ticket{ + Ticket: acc, + } + renderJSON(http.StatusOK, w, req, response) +} + func getRandomString(n int) (string, error) { b := make([]rune, n) for i := range b { diff --git a/config.go b/config.go index 041c1788..d7086a30 100644 --- a/config.go +++ b/config.go @@ -76,6 +76,9 @@ func newDefaultConfig() *Config { UpstreamTimeout: 10 * time.Second, UseLetsEncrypt: false, ForwardingGrantType: GrantTypeUserCreds, + PatRetryCount: 5, + PatRetryInterval: 10 * time.Second, + PatRefreshInterval: 33 * time.Second, } } @@ -88,6 +91,21 @@ func (r *Config) WithOAuthURI(uri string) string { return fmt.Sprintf("%s/%s", r.OAuthURI, uri) } +func (r *Config) update() error { + updateRegistry := []func() error{ + r.updateDiscoveryURI, + r.updateRealm, + } + + for _, updateFunc := range updateRegistry { + if err := updateFunc(); err != nil { + return err + } + } + + return nil +} + // isValid validates if the config is valid func (r *Config) isValid() error { if r.ListenAdmin == r.Listen { @@ -323,6 +341,7 @@ func (r *Config) isReverseProxySettingsValid() error { if !r.EnableForwarding { validationRegistry := []func() error{ r.isUpstreamValid, + r.isEnableUmaValid, r.isTokenVerificationSettingsValid, r.isResourceValid, r.isMatchClaimValid, @@ -535,3 +554,48 @@ func (r *Config) isMatchClaimValid() error { return nil } + +func (r *Config) isEnableUmaValid() error { + if r.EnableUma { + if r.ClientID == "" || r.ClientSecret == "" { + return errors.New( + "enable uma requires client credentials", + ) + } + } + return nil +} + +func (r *Config) updateDiscoveryURI() error { + // step: fix up the url if required, the underlining lib will add + // the .well-known/openid-configuration to the discovery url for us. + r.DiscoveryURL = strings.TrimSuffix( + r.DiscoveryURL, + "/.well-known/openid-configuration", + ) + + u, err := url.ParseRequestURI(r.DiscoveryURL) + + if err != nil { + return fmt.Errorf( + "failed to parse discovery url: %w", + err, + ) + } + + r.DiscoveryURI = u + + return nil +} + +func (r *Config) updateRealm() error { + path := strings.Split(r.DiscoveryURI.Path, "/") + + if len(path) != 4 { + return fmt.Errorf("missing realm in discovery url?") + } + + realm := path[len(path)-1] + r.Realm = realm + return nil +} diff --git a/config_test.go b/config_test.go index d25a7d43..94c20d9c 100644 --- a/config_test.go +++ b/config_test.go @@ -22,6 +22,7 @@ import ( "fmt" "io/ioutil" "math/rand" + "net/url" "os" "testing" ) @@ -1778,3 +1779,144 @@ func TestIsMatchClaimValid(t *testing.T) { ) } } + +func TestEnableUmaValid(t *testing.T) { + testCases := []struct { + Name string + Config *Config + Valid bool + }{ + { + Name: "ValidEnableUma", + Config: &Config{ + EnableUma: true, + ClientID: "test", + ClientSecret: "test", + }, + Valid: true, + }, + { + Name: "MissingClientID", + Config: &Config{ + EnableUma: true, + ClientID: "", + ClientSecret: "test", + }, + Valid: false, + }, + { + Name: "MissingClientSecret", + Config: &Config{ + EnableUma: true, + ClientID: "test", + ClientSecret: "", + }, + Valid: false, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run( + testCase.Name, + func(t *testing.T) { + err := testCase.Config.isEnableUmaValid() + if err != nil && testCase.Valid { + t.Fatalf("Expected test not to fail") + } + + if err == nil && !testCase.Valid { + t.Fatalf("Expected test to fail") + } + }, + ) + } +} + +func TestUpdateDiscoveryURI(t *testing.T) { + testCases := []struct { + Name string + Config *Config + Valid bool + }{ + { + Name: "OK", + Config: &Config{ + DiscoveryURL: "http://127.0.0.1:8081/auth/realms/test/.well-known/openid-configuration", + }, + Valid: true, + }, + { + Name: "InValidDiscoveryURL", + Config: &Config{ + DiscoveryURL: "://127.0.0.1:8081/auth/realms/test/.well-known/openid-configuration", + }, + Valid: false, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run( + testCase.Name, + func(t *testing.T) { + err := testCase.Config.updateDiscoveryURI() + if err != nil && testCase.Valid { + t.Fatalf("Expected test not to fail") + } + + if err == nil && !testCase.Valid { + t.Fatalf("Expected test to fail") + } + }, + ) + } +} + +func TestUpdateRealm(t *testing.T) { + testCases := []struct { + Name string + Config *Config + Valid bool + }{ + { + Name: "OK", + Config: &Config{ + DiscoveryURI: &url.URL{ + Scheme: "http", + Host: "127.0.0.1", + Path: "/auth/realms/test", + }, + }, + Valid: true, + }, + { + Name: "InValidDiscoveryURL", + Config: &Config{ + DiscoveryURI: &url.URL{ + Scheme: "http", + Host: "127.0.0.1", + Path: "/auth/realms", + }, + }, + Valid: false, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run( + testCase.Name, + func(t *testing.T) { + err := testCase.Config.updateRealm() + if err != nil && testCase.Valid { + t.Fatalf("Expected test not to fail") + } + + if err == nil && !testCase.Valid { + t.Fatalf("Expected test to fail") + } + }, + ) + } +} diff --git a/doc.go b/doc.go index db16f492..a210ba77 100644 --- a/doc.go +++ b/doc.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strconv" "time" @@ -248,6 +249,11 @@ type Config struct { // EnableCompression enables gzip compression for response EnableCompression bool `json:"enable-compression" yaml:"enable-compression" usage:"enable gzip compression for response" env:"ENABLE_COMPRESSION"` + EnableUma bool `json:"enable-uma" yaml:"enable-uma" usage:"enable uma authorization" env:"ENABLE_UMA"` + PatRetryCount int `json:"pat-retry-count" yaml:"pat-retry-count" usage:"number of retries to get PAT" env:"PAT_RETRY_COUNT"` + PatRetryInterval time.Duration `json:"pat-retry-interval" yaml:"pat-retry-interval" usage:"interval between retries to get PAT" env:"PAT_RETRY_INTERVAL"` + PatRefreshInterval time.Duration `json:"pat-refresh-interval" yaml:"pat-refresh-interval" usage:"interval between requesting new PAT" env:"PAT_REFRESH_INTERVAL"` + // AccessTokenDuration is default duration applied to the access token cookie AccessTokenDuration time.Duration `json:"access-token-duration" yaml:"access-token-duration" usage:"fallback cookie duration for the access token when using refresh tokens" env:"ACCESS_TOKEN_DURATION"` // CookieDomain is a list of domains the cookie is available to @@ -381,6 +387,9 @@ type Config struct { // DisableAllLogging indicates no logging at all DisableAllLogging bool `json:"disable-all-logging" yaml:"disable-all-logging" usage:"disables all logging to stdout and stderr" env:"DISABLE_ALL_LOGGING"` + // this is non-configurable field, derived from discoveryurl at initialization + Realm string + DiscoveryURI *url.URL } // getVersion returns the proxy version @@ -427,6 +436,16 @@ type reverseProxy interface { ServeHTTP(rw http.ResponseWriter, req *http.Request) } +type Permission struct { + Scopes []string `json:"scopes"` + ResourceID string `json:"rsid"` + ResourceName string `json:"rsname"` +} + +type Permissions struct { + Permissions []Permission `json:"permissions"` +} + // userContext holds the information extracted the token type userContext struct { // the id of the user @@ -451,6 +470,8 @@ type userContext struct { rawToken string // claims claims map[string]interface{} + // permissions + permissions Permissions } // tokenResponse diff --git a/go.mod b/go.mod index f02cf900..f6c6792a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,7 @@ module github.com/gogatekeeper/gatekeeper require ( + github.com/Nerzal/gocloak/v11 v11.0.2 github.com/PuerkitoBio/purell v1.1.2-0.20201208131035-5a810c9252c4 github.com/alicebob/miniredis/v2 v2.14.5 github.com/armon/go-proxyproto v0.0.0-20200108142055-f0b8253b1507 @@ -31,7 +32,6 @@ require ( require ( github.com/BurntSushi/toml v0.3.1 // indirect - github.com/Nerzal/gocloak/v11 v11.0.2 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/middleware.go b/middleware.go index b0b6418d..9ed0fa19 100644 --- a/middleware.go +++ b/middleware.go @@ -27,6 +27,7 @@ import ( "sync" "time" + "github.com/Nerzal/gocloak/v11" uuid "github.com/gofrs/uuid" "github.com/PuerkitoBio/purell" @@ -444,6 +445,114 @@ func (r *oauthProxy) authenticationMiddleware() func(http.Handler) http.Handler } } +// authorizationMiddleware is responsible for verifying permissions in access_token +// nolint:funlen +func (r *oauthProxy) authorizationMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + scope := req.Context().Value(contextScopeName).(*RequestScope) + + if scope.AccessDenied { + next.ServeHTTP(w, req) + return + } + + user := scope.Identity + + if len(user.permissions.Permissions) == 0 { + r.log.Info( + "permissions not found in token, " + + "redirecting for authorization", + ) + + next.ServeHTTP(w, req.WithContext(r.redirectToAuthorization(w, req))) + return + } + + resctx, 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( + resctx, + token, + r.config.Realm, + resourceParam, + ) + + if err != nil { + r.log.Info( + "problem getting resource from IDP, " + + "redirecting for authorization", + ) + + next.ServeHTTP(w, req.WithContext(r.redirectToAuthorization(w, req))) + return + } + + if len(resources) == 0 { + r.log.Info( + "seems there is no resource in IDP matching incoming URI path", + ) + w.WriteHeader(http.StatusUnauthorized) + next.ServeHTTP(w, req.WithContext(r.revokeProxy(w, req))) + return + } + + resourceID := resources[0].ID + + if *resourceID != user.permissions.Permissions[0].ResourceID { + r.log.Info( + "token resource id does not match IDP resource " + + "id for path, redirecting for authorization", + ) + + next.ServeHTTP(w, req.WithContext(r.redirectToAuthorization(w, req))) + return + } + + inter := make([]bool, 0) + permScopes := make(map[string]bool) + + for _, scope := range *resources[0].ResourceScopes { + permScopes[*scope.Name] = true + } + + for _, scope := range user.permissions.Permissions[0].Scopes { + if permScopes[scope] { + inter = append(inter, true) + } + } + + if len(inter) == 0 { + r.log.Info( + "token scopes does not match with IDP " + + "resource scopes, redirecting for authorization", + ) + + next.ServeHTTP(w, req.WithContext(r.redirectToAuthorization(w, req))) + return + } + + next.ServeHTTP(w, req) + }) + } +} + // checkClaim checks whether claim in userContext matches claimName, match. It can be String or Strings claim. func (r *oauthProxy) checkClaim(user *userContext, claimName string, match *regexp.Regexp, resourceURL string) bool { errFields := []zapcore.Field{ diff --git a/middleware_test.go b/middleware_test.go index 252e1fd5..f37d1dd1 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -1831,3 +1831,175 @@ func TestGzipCompression(t *testing.T) { ) } } + +func TestEnableUma(t *testing.T) { + cfg := newFakeKeycloakConfig() + + requests := []struct { + Name string + ProxySettings func(c *Config) + ExecutionSettings []fakeRequest + }{ + { + Name: "TestUmaNoToken", + ProxySettings: func(c *Config) { + c.EnableUma = true + c.EnableDefaultDeny = true + c.ClientID = validUsername + c.ClientSecret = validPassword + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/test", + ExpectedProxy: false, + ExpectedCode: http.StatusUnauthorized, + ExpectedContent: func(body string, testNum int) { + assert.Equal(t, "", body) + }, + ExpectedProxyHeadersValidator: map[string]func(*testing.T, *Config, string){ + "WWW-Authenticate": func(t *testing.T, c *Config, value string) { + assert.Contains(t, "ticket", value) + }, + }, + }, + }, + }, + { + Name: "TestUmaTokenWithoutAuthz", + ProxySettings: func(c *Config) { + c.EnableUma = true + c.EnableDefaultDeny = true + c.ClientID = validUsername + c.ClientSecret = validPassword + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/test", + ExpectedProxy: false, + HasToken: true, + ExpectedCode: http.StatusUnauthorized, + TokenAuthorization: &Permissions{}, + ExpectedContent: func(body string, testNum int) { + assert.Equal(t, "", body) + }, + ExpectedProxyHeadersValidator: map[string]func(*testing.T, *Config, string){ + "WWW-Authenticate": func(t *testing.T, c *Config, value string) { + assert.Contains(t, "ticket", value) + }, + }, + }, + }, + }, + { + Name: "TestUmaTokenWithoutResourceId", + ProxySettings: func(c *Config) { + c.EnableUma = true + c.EnableDefaultDeny = true + c.ClientID = validUsername + c.ClientSecret = validPassword + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/test", + ExpectedProxy: false, + HasToken: true, + ExpectedCode: http.StatusUnauthorized, + TokenAuthorization: &Permissions{ + Permissions: []Permission{ + { + Scopes: []string{"test"}, + ResourceID: "", + ResourceName: "some", + }, + }, + }, + ExpectedContent: func(body string, testNum int) { + assert.Equal(t, "", body) + }, + ExpectedProxyHeadersValidator: map[string]func(*testing.T, *Config, string){ + "WWW-Authenticate": func(t *testing.T, c *Config, value string) { + assert.Contains(t, "ticket", value) + }, + }, + }, + }, + }, + { + Name: "TestUmaTokenWithoutScope", + ProxySettings: func(c *Config) { + c.EnableUma = true + c.EnableDefaultDeny = true + c.ClientID = validUsername + c.ClientSecret = validPassword + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/test", + ExpectedProxy: false, + HasToken: true, + ExpectedCode: http.StatusUnauthorized, + TokenAuthorization: &Permissions{ + Permissions: []Permission{ + { + Scopes: []string{}, + ResourceID: "6ef1b62e-0fd4-47f2-81fc-eead97a01c22", + ResourceName: "some", + }, + }, + }, + ExpectedContent: func(body string, testNum int) { + assert.Equal(t, "", body) + }, + ExpectedProxyHeadersValidator: map[string]func(*testing.T, *Config, string){ + "WWW-Authenticate": func(t *testing.T, c *Config, value string) { + assert.Contains(t, "ticket", value) + }, + }, + }, + }, + }, + { + Name: "TestUmaOK", + ProxySettings: func(c *Config) { + c.EnableUma = true + c.EnableDefaultDeny = true + c.ClientID = validUsername + c.ClientSecret = validPassword + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/test", + ExpectedProxy: true, + HasToken: true, + ExpectedCode: http.StatusOK, + TokenAuthorization: &Permissions{ + Permissions: []Permission{ + { + Scopes: []string{"test"}, + ResourceID: "6ef1b62e-0fd4-47f2-81fc-eead97a01c22", + ResourceName: "some", + }, + }, + }, + ExpectedContent: func(body string, testNum int) { + assert.Contains(t, body, "test") + assert.Contains(t, body, "method") + }, + }, + }, + }, + } + + for _, testCase := range requests { + testCase := testCase + c := cfg + t.Run( + testCase.Name, + func(t *testing.T) { + testCase.ProxySettings(c) + p := newFakeProxy(c, &fakeAuthConfig{}) + p.RunTests(t, testCase.ExecutionSettings) + }, + ) + } +} diff --git a/misc.go b/misc.go index 9db71ea2..944d6523 100644 --- a/misc.go +++ b/misc.go @@ -23,6 +23,7 @@ import ( "strings" "time" + "github.com/Nerzal/gocloak/v11" "go.uber.org/zap" "gopkg.in/square/go-jose.v2/jwt" ) @@ -122,7 +123,98 @@ func (r *oauthProxy) redirectToURL(url string, w http.ResponseWriter, req *http. // redirectToAuthorization redirects the user to authorization handler func (r *oauthProxy) redirectToAuthorization(w http.ResponseWriter, req *http.Request) context.Context { - if r.config.NoRedirects { + if r.config.NoRedirects && !r.config.EnableUma { + w.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(w, 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), + ) + w.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(w, 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), + ) + w.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(w, req) + } + + for _, scope := range *resources[0].ResourceScopes { + resourceScopes = append(resourceScopes, *scope.Name) + } + + 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), + ) + w.WriteHeader(http.StatusUnauthorized) + return r.revokeProxy(w, req) + } + + permHeader := fmt.Sprintf( + `realm="%s", as_uri="%s", ticket="%s"`, + r.config.Realm, + r.config.DiscoveryURI.Host, + *permTicket.Ticket, + ) + + w.Header().Add( + "WWW-Authenticate", + permHeader, + ) w.WriteHeader(http.StatusUnauthorized) return r.revokeProxy(w, req) } @@ -142,7 +234,12 @@ func (r *oauthProxy) redirectToAuthorization(w http.ResponseWriter, req *http.Re return r.revokeProxy(w, req) } - r.redirectToURL(r.config.WithOAuthURI(authorizationURL+authQuery), w, req, http.StatusSeeOther) + r.redirectToURL( + r.config.WithOAuthURI(authorizationURL+authQuery), + w, + req, + http.StatusSeeOther, + ) return r.revokeProxy(w, req) } diff --git a/server.go b/server.go index 18db9b16..51b7215c 100644 --- a/server.go +++ b/server.go @@ -31,6 +31,7 @@ import ( "path" "runtime" "strings" + "sync" "time" "go.uber.org/zap/zapcore" @@ -51,6 +52,11 @@ import ( "go.uber.org/zap" ) +type PAT struct { + Token *gocloak.JWT + m sync.Mutex +} + type oauthProxy struct { provider *oidc3.Provider config *Config @@ -65,6 +71,7 @@ type oauthProxy struct { store storage templates *template.Template upstream reverseProxy + pat *PAT } func init() { @@ -86,6 +93,12 @@ func newProxy(config *Config) (*oauthProxy, error) { return nil, err } + err = config.update() + + if err != nil { + return nil, err + } + log.Info( "starting the service", zap.String("prog", prog), @@ -128,6 +141,12 @@ func newProxy(config *Config) (*oauthProxy, error) { svc.log.Info("successfully retrieved openid configuration from the discovery") + if config.EnableUma { + patDone := make(chan bool) + go svc.getPAT(patDone) + <-patDone + } + if config.SkipTokenVerification { log.Warn( "TESTING ONLY CONFIG - access token verification has been disabled", @@ -386,10 +405,22 @@ func (r *oauthProxy) createReverseProxy() error { zap.String("resource", x.String()), ) - e := engine.With( + middlewares := []func(http.Handler) http.Handler{ r.authenticationMiddleware(), r.admissionMiddleware(x), - r.identityHeadersMiddleware(r.config.AddClaims)) + r.identityHeadersMiddleware(r.config.AddClaims), + } + + if r.config.EnableUma { + middlewares = []func(http.Handler) http.Handler{ + r.authenticationMiddleware(), + r.authorizationMiddleware(), + r.admissionMiddleware(x), + r.identityHeadersMiddleware(r.config.AddClaims), + } + } + + e := engine.With(middlewares...) for _, m := range x.Methods { if !x.WhiteListed { @@ -970,13 +1001,12 @@ func (r *oauthProxy) createTemplates() error { // newOpenIDProvider initializes the openID configuration, note: the redirection url is deliberately left blank // in order to retrieve it from the host header on request func (r *oauthProxy) newOpenIDProvider() (*oidc3.Provider, gocloak.GoCloak, error) { - // step: fix up the url if required, the underlining lib will add the .well-known/openid-configuration to the discovery url for us. - r.config.DiscoveryURL = strings.TrimSuffix( - r.config.DiscoveryURL, - "/.well-known/openid-configuration", + host := fmt.Sprintf( + "%s://%s", + r.config.DiscoveryURI.Scheme, + r.config.DiscoveryURI.Host, ) - - client := gocloak.NewClient(r.config.DiscoveryURL) + client := gocloak.NewClient(host) restyClient := client.RestyClient() restyClient.SetDebug(r.config.Verbose) restyClient.SetTimeout(r.config.OpenIDProviderTimeout) @@ -1011,3 +1041,56 @@ func (r *oauthProxy) newOpenIDProvider() (*oidc3.Provider, gocloak.GoCloak, erro func (r *oauthProxy) Render(w io.Writer, name string, data interface{}) error { return r.templates.ExecuteTemplate(w, name, data) } + +func (r *oauthProxy) getPAT(done chan bool) { + retry := 0 + r.pat = &PAT{} + + for { + if retry > 0 { + r.log.Info( + "retrying fetching PAT token", + zap.Int("retry", retry), + ) + } + + ctx, cancel := context.WithTimeout( + context.Background(), + r.config.OpenIDProviderTimeout, + ) + clientID := r.config.ClientID + clientSecret := r.config.ClientSecret + + token, err := r.idpClient.LoginClient( + ctx, + clientID, + clientSecret, + r.config.Realm, + ) + + if err != nil { + retry++ + r.log.Error( + "problem getting PAT token", + zap.Error(err), + ) + + if retry >= r.config.PatRetryCount { + cancel() + os.Exit(10) + } + + time.Sleep(r.config.PatRetryInterval) + continue + } + + r.pat.m.Lock() + r.pat.Token = token + r.pat.m.Unlock() + + done <- true + + retry = 0 + time.Sleep(r.config.PatRefreshInterval) + } +} diff --git a/user_context.go b/user_context.go index 6175fa2f..5d9d8519 100644 --- a/user_context.go +++ b/user_context.go @@ -41,6 +41,7 @@ func extractIdentity(token *jwt.JSONWebToken) (*userContext, error) { FamilyName string `json:"family_name"` GivenName string `json:"given_name"` Username string `json:"username"` + Authorization Permissions `json:"authorization"` } customClaims := custClaims{} @@ -91,6 +92,7 @@ func extractIdentity(token *jwt.JSONWebToken) (*userContext, error) { preferredName: preferredName, roles: roleList, claims: jsonMap, + permissions: customClaims.Authorization, }, nil }