diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 000000000..b1e3b426f --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,26 @@ +codecov: + notify: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "70...100" + + status: + project: yes + patch: no + changes: no + +parsers: + gcov: + branch_detection: + conditional: yes + loop: yes + method: no + macro: no + +comment: + layout: "header, diff" + behavior: default + require_changes: no diff --git a/.gitignore b/.gitignore index 5c66578f5..cbec6f9f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.docker-compose.yml +.*.yml pem env coverage.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2273219c2..774356d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ ## Unreleased -FEATURES: +**FEATURES:** + * **Authorization** : The authorization module adds support for per-route access policy. In this release we support the most common forms of identity based access policy: `allowed_users`, `allowed_groups`, and `allowed_domains`. In future versions, the authorization module will also support context and device based authorization policy and decisions. See website documentation for more details. * **Group Support** : The authenticate service now retrieves a user's group membership information during authentication and refresh. This change may require additional identity provider configuration; all of which are described in the [updated docs](https://www.pomerium.io/docs/identity-providers.html). A brief summary of the requirements for each IdP are as follows: - Google requires the [Admin SDK](https://developers.google.com/admin-sdk/directory/) to enabled, a service account with properly delegated access, and `IDP_SERVICE_ACCOUNT` to be set to the base64 encoded value of the service account's key file. - Okta requires a `groups` claim to be added to both the `id_token` and `access_token`. No additional API calls are made. @@ -11,25 +12,22 @@ FEATURES: - Onelogin requires the [groups](https://developers.onelogin.com/openid-connect/scopes) was supplied during authentication and that groups parameter has been mapped. Group membership is validated on refresh with the [user-info api endpoint](https://developers.onelogin.com/openid-connect/api/user-info). * **WebSocket Support** : With [Go 1.12](https://golang.org/doc/go1.12#net/http/httputil) pomerium automatically proxies WebSocket requests. +**CHANGED**: -CHANGED: - + * Updated `env.example` to include a `POLICY` setting example. + * Added `IDP_SERVICE_ACCOUNT` to `env.example` . + * Removed `PROXY_ROOT_DOMAIN` settings which has been replaced by `POLICY`. + * Removed `ALLOWED_DOMAINS` settings which has been replaced by `POLICY`. Authorization is now handled by the authorization service and is defined in the policy configuration files. + * Removed `ROUTES` settings which has been replaced by `POLICY`. * Add refresh endpoint `${url}/.pomerium/refresh` which forces a token refresh and responds with the json result. * Group membership added to proxy headers (`x-pomerium-authenticated-user-groups`) and (`x-pomerium-jwt-assertion`). * Default Cookie lifetime (`COOKIE_EXPIRE`) changed from 7 days to 14 hours ~ roughly one business day. - * Moved identity (`authenticate/providers`) into it's own internal identity package as third party identity providers are going to authorization details (group membership, user role, etc) in addition to just authentication attributes. - * Removed circuit breaker package. Calls that were previously wrapped with a circuit breaker fall under gRPC timeouts; which are gated by relatively short deadlines. + * Moved identity (`authenticate/providers`) into its own internal identity package as third party identity providers are going to authorization details (group membership, user role, etc) in addition to just authentication attributes. + * Removed circuit breaker package. Calls that were previously wrapped with a circuit breaker fall under gRPC timeouts; which are gated by relatively short timeouts. * Session expiration times are truncated at the second. * **Removed gitlab provider**. We can't support groups until [this gitlab bug](https://gitlab.com/gitlab-org/gitlab-ce/issues/44435#note_88150387) is fixed. - -IMPROVED: + * Request context is now maintained throughout request-flow via the [context package](https://golang.org/pkg/context/) enabling timeouts, request tracing, and cancellation. - * Request context is now maintained throughout request-flow via the [context package](https://golang.org/pkg/context/) enabling deadlines, request tracing, and cancellation. +**FIXED:** -FIXED: - - * - -SECURITY: - - * \ No newline at end of file +* `http.Server` and `httputil.NewSingleHostReverseProxy` now uses pomerium's logging package instead of the standard library's built in one. [GH-58] diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go index 8f3b58fc6..7717ebfa2 100644 --- a/authenticate/authenticate.go +++ b/authenticate/authenticate.go @@ -31,11 +31,7 @@ type Options struct { SharedKey string `envconfig:"SHARED_SECRET"` // RedirectURL specifies the callback url following third party authentication - RedirectURL *url.URL `envconfig:"REDIRECT_URL"` - - // Coarse authorization based on user email domain - // todo(bdd) : to be replaced with authorization module - AllowedDomains []string `envconfig:"ALLOWED_DOMAINS"` + RedirectURL *url.URL `envconfig:"REDIRECT_URL"` ProxyRootDomains []string `envconfig:"PROXY_ROOT_DOMAIN"` // Session/Cookie management @@ -84,9 +80,6 @@ func (o *Options) Validate() error { if o.ClientSecret == "" { return errors.New("missing setting: client secret") } - if len(o.AllowedDomains) == 0 { - return errors.New("missing setting email domain") - } if len(o.ProxyRootDomains) == 0 { return errors.New("missing setting: proxy root domain") } @@ -105,14 +98,10 @@ func (o *Options) Validate() error { // Authenticate validates a user's identity type Authenticate struct { - SharedKey string - + SharedKey string RedirectURL *url.URL - AllowedDomains []string ProxyRootDomains []string - Validator func(string) bool - templates *template.Template csrfStore sessions.CSRFStore sessionStore sessions.SessionStore @@ -122,9 +111,9 @@ type Authenticate struct { } // New validates and creates a new authenticate service from a set of Options -func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate, error) { +func New(opts *Options) (*Authenticate, error) { if opts == nil { - return nil, errors.New("options cannot be nil") + return nil, errors.New("authenticate: options cannot be nil") } if err := opts.Validate(); err != nil { return nil, err @@ -166,7 +155,6 @@ func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate p := &Authenticate{ SharedKey: opts.SharedKey, RedirectURL: opts.RedirectURL, - AllowedDomains: opts.AllowedDomains, ProxyRootDomains: dotPrependDomains(opts.ProxyRootDomains), templates: templates.New(), @@ -176,14 +164,6 @@ func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate provider: provider, } - // validation via dependency injected function - for _, optFunc := range optionFuncs { - err := optFunc(p) - if err != nil { - return nil, err - } - } - return p, nil } diff --git a/authenticate/authenticate_test.go b/authenticate/authenticate_test.go index 8aa3ee23b..1f12fa549 100644 --- a/authenticate/authenticate_test.go +++ b/authenticate/authenticate_test.go @@ -12,7 +12,6 @@ func testOptions() *Options { redirectURL, _ := url.Parse("https://example.com/oauth2/callback") return &Options{ ProxyRootDomains: []string{"example.com"}, - AllowedDomains: []string{"example.com"}, RedirectURL: redirectURL, SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=", ClientID: "test-client-id", @@ -35,8 +34,6 @@ func TestOptions_Validate(t *testing.T) { emptyClientID.ClientID = "" emptyClientSecret := testOptions() emptyClientSecret.ClientSecret = "" - allowedDomains := testOptions() - allowedDomains.AllowedDomains = nil proxyRootDomains := testOptions() proxyRootDomains.ProxyRootDomains = nil emptyCookieSecret := testOptions() @@ -63,7 +60,6 @@ func TestOptions_Validate(t *testing.T) { {"no shared secret", badSharedKey, true}, {"no client id", emptyClientID, true}, {"no client secret", emptyClientSecret, true}, - {"empty allowed domains", allowedDomains, true}, {"empty root domains", proxyRootDomains, true}, } for _, tt := range tests { diff --git a/authenticate/grpc.go b/authenticate/grpc.go index 0d9618f0f..232dabad0 100644 --- a/authenticate/grpc.go +++ b/authenticate/grpc.go @@ -1,3 +1,5 @@ +//go:generate protoc -I ../proto/authenticate --go_out=plugins=grpc:../proto/authenticate ../proto/authenticate/authenticate.proto + package authenticate // import "github.com/pomerium/pomerium/authenticate" import ( "context" @@ -20,7 +22,7 @@ func (p *Authenticate) Authenticate(ctx context.Context, in *pb.AuthenticateRequ return newSessionProto, nil } -// Validate locally validates a JWT id token; does NOT do nonce or revokation validation. +// Validate locally validates a JWT id_token; does NOT do nonce or revokation validation. // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation func (p *Authenticate) Validate(ctx context.Context, in *pb.ValidateRequest) (*pb.ValidateReply, error) { isValid, err := p.provider.Validate(ctx, in.IdToken) diff --git a/authenticate/grpc_test.go b/authenticate/grpc_test.go index 5dc92299a..823f87514 100644 --- a/authenticate/grpc_test.go +++ b/authenticate/grpc_test.go @@ -78,9 +78,6 @@ func TestAuthenticate_Refresh(t *testing.T) { false}, {"test error", &identity.MockProvider{RefreshError: errors.New("hi")}, &pb.Session{RefreshToken: "refresh token", RefreshDeadline: fixedProtoTime, LifetimeDeadline: fixedProtoTime}, nil, true}, {"test catch nil", nil, nil, nil, true}, - - // {"test error", "error", nil, true}, - // {"test bad time", "bad time", nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/authenticate/handlers.go b/authenticate/handlers.go index 6611488c0..70b41ef7a 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -81,7 +81,6 @@ func (a *Authenticate) authenticate(w http.ResponseWriter, r *http.Request) (*se return nil, err } - // check if session refresh period is up if session.RefreshPeriodExpired() { newSession, err := a.provider.Refresh(r.Context(), session) if err != nil { @@ -111,12 +110,6 @@ func (a *Authenticate) authenticate(w http.ResponseWriter, r *http.Request) (*se } } - // authenticate really should not be in the business of authorization - // todo(bdd) : remove when authorization module added - if !a.Validator(session.Email) { - log.FromRequest(r).Error().Msg("invalid email user") - return nil, httputil.ErrUserNotAuthorized - } return session, nil } @@ -238,12 +231,7 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) { // OAuthStart starts the authenticate process by redirecting to the identity provider. // https://tools.ietf.org/html/rfc6749#section-4.2.1 func (a *Authenticate) OAuthStart(w http.ResponseWriter, r *http.Request) { - authRedirectURL, err := url.Parse(r.URL.Query().Get("redirect_uri")) - if err != nil { - httputil.ErrorResponse(w, r, "Invalid redirect parameter", http.StatusBadRequest) - return - } - authRedirectURL = a.RedirectURL.ResolveReference(r.URL) + authRedirectURL := a.RedirectURL.ResolveReference(r.URL) nonce := fmt.Sprintf("%x", cryptutil.GenerateKey()) a.csrfStore.SetCSRF(w, r, nonce) @@ -345,11 +333,6 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) return "", httputil.HTTPError{Code: http.StatusForbidden, Message: "Invalid Redirect URI"} } - // Set cookie, or deny: validates the session email and group - if !a.Validator(session.Email) { - log.FromRequest(r).Error().Err(err).Str("email", session.Email).Msg("invalid email permissions denied") - return "", httputil.HTTPError{Code: http.StatusForbidden, Message: "You don't have access"} - } err = a.sessionStore.SaveSession(w, r, session) if err != nil { log.Error().Err(err).Msg("internal error") diff --git a/authenticate/handlers_test.go b/authenticate/handlers_test.go index f2424309e..64d12d78f 100644 --- a/authenticate/handlers_test.go +++ b/authenticate/handlers_test.go @@ -17,15 +17,10 @@ import ( "github.com/pomerium/pomerium/internal/templates" ) -// mocks for validator func -func trueValidator(s string) bool { return true } -func falseValidator(s string) bool { return false } - func testAuthenticate() *Authenticate { var auth Authenticate auth.RedirectURL, _ = url.Parse("https://auth.example.com/oauth/callback") auth.SharedKey = "IzY7MOZwzfOkmELXgozHDKTxoT3nOYhwkcmUVINsRww=" - auth.AllowedDomains = []string{"*"} auth.ProxyRootDomains = []string{"example.com"} auth.templates = templates.New() return &auth @@ -86,66 +81,25 @@ func TestAuthenticate_authenticate(t *testing.T) { }} tests := []struct { - name string - session sessions.SessionStore - provider identity.MockProvider - validator func(string) bool - want *sessions.SessionState - wantErr bool + name string + session sessions.SessionStore + provider identity.MockProvider + want *sessions.SessionState + wantErr bool }{ - {"good", goodSession, identity.MockProvider{ValidateResponse: true}, trueValidator, nil, false}, - {"good but fails validation", goodSession, identity.MockProvider{ValidateResponse: true}, falseValidator, nil, true}, - {"can't load session", &sessions.MockSessionStore{LoadError: errors.New("error")}, identity.MockProvider{ValidateResponse: true}, trueValidator, nil, true}, - {"validation fails", goodSession, identity.MockProvider{ValidateResponse: false}, trueValidator, nil, true}, - {"session fails after good validation", &sessions.MockSessionStore{ - SaveError: errors.New("error"), - Session: &sessions.SessionState{ - AccessToken: "AccessToken", - RefreshToken: "RefreshToken", - RefreshDeadline: time.Now().Add(10 * time.Second), - }}, identity.MockProvider{ValidateResponse: true}, - trueValidator, nil, true}, - {"refresh expired", - expiredRefresPeriod, - identity.MockProvider{ - ValidateResponse: true, - RefreshResponse: &sessions.SessionState{ - AccessToken: "new token", - LifetimeDeadline: time.Now(), - }, - }, - trueValidator, nil, false}, - {"refresh expired refresh error", - expiredRefresPeriod, - identity.MockProvider{ - ValidateResponse: true, - RefreshError: errors.New("error"), - }, - trueValidator, nil, true}, - {"refresh expired failed save", - &sessions.MockSessionStore{ - SaveError: errors.New("error"), - Session: &sessions.SessionState{ - AccessToken: "AccessToken", - RefreshToken: "RefreshToken", - - RefreshDeadline: time.Now().Add(10 * -time.Second), - }}, - identity.MockProvider{ - ValidateResponse: true, - RefreshResponse: &sessions.SessionState{ - AccessToken: "new token", - LifetimeDeadline: time.Now(), - }, - }, - trueValidator, nil, true}, + {"good", goodSession, identity.MockProvider{ValidateResponse: true}, nil, false}, + {"can't load session", &sessions.MockSessionStore{LoadError: errors.New("error")}, identity.MockProvider{ValidateResponse: true}, nil, true}, + {"validation fails", goodSession, identity.MockProvider{ValidateResponse: false}, nil, true}, + {"session fails after good validation", &sessions.MockSessionStore{SaveError: errors.New("error"), Session: &sessions.SessionState{AccessToken: "AccessToken", RefreshToken: "RefreshToken", RefreshDeadline: time.Now().Add(10 * time.Second)}}, identity.MockProvider{ValidateResponse: true}, nil, true}, + {"refresh expired", expiredRefresPeriod, identity.MockProvider{ValidateResponse: true, RefreshResponse: &sessions.SessionState{AccessToken: "new token", LifetimeDeadline: time.Now()}}, nil, false}, + {"refresh expired refresh error", expiredRefresPeriod, identity.MockProvider{ValidateResponse: true, RefreshError: errors.New("error")}, nil, true}, + {"refresh expired failed save", &sessions.MockSessionStore{SaveError: errors.New("error"), Session: &sessions.SessionState{AccessToken: "AccessToken", RefreshToken: "RefreshToken", RefreshDeadline: time.Now().Add(10 * -time.Second)}}, identity.MockProvider{ValidateResponse: true, RefreshResponse: &sessions.SessionState{AccessToken: "new token", LifetimeDeadline: time.Now()}}, nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &Authenticate{ sessionStore: tt.session, provider: tt.provider, - Validator: tt.validator, } r := httptest.NewRequest("GET", "/auth", nil) w := httptest.NewRecorder() @@ -161,11 +115,10 @@ func TestAuthenticate_authenticate(t *testing.T) { func TestAuthenticate_SignIn(t *testing.T) { tests := []struct { - name string - session sessions.SessionStore - provider identity.MockProvider - validator func(string) bool - wantCode int + name string + session sessions.SessionStore + provider identity.MockProvider + wantCode int }{ {"good", &sessions.MockSessionStore{ @@ -175,7 +128,7 @@ func TestAuthenticate_SignIn(t *testing.T) { RefreshDeadline: time.Now().Add(10 * time.Second), }}, identity.MockProvider{ValidateResponse: true}, - trueValidator, + http.StatusForbidden}, {"session fails after good validation", &sessions.MockSessionStore{ SaveError: errors.New("error"), @@ -184,14 +137,13 @@ func TestAuthenticate_SignIn(t *testing.T) { RefreshToken: "RefreshToken", RefreshDeadline: time.Now().Add(10 * time.Second), }}, identity.MockProvider{ValidateResponse: true}, - trueValidator, http.StatusBadRequest}, + http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { a := &Authenticate{ sessionStore: tt.session, provider: tt.provider, - Validator: tt.validator, RedirectURL: uriParse("http://www.pomerium.io"), csrfStore: &sessions.MockCSRFStore{}, SharedKey: "secret", @@ -592,11 +544,9 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { code string state string validDomains []string - validator func(string) bool - - session sessions.SessionStore - provider identity.MockProvider - csrfStore sessions.MockCSRFStore + session sessions.SessionStore + provider identity.MockProvider + csrfStore sessions.MockCSRFStore want string wantErr bool @@ -607,7 +557,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")), []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ @@ -629,7 +579,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")), []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ @@ -652,7 +602,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")), []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ @@ -674,7 +624,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")), []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateError: errors.New("error"), @@ -691,30 +641,8 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")), []string{"pomerium.io"}, - trueValidator, - &sessions.MockSessionStore{SaveError: errors.New("error")}, - identity.MockProvider{ - AuthenticateResponse: sessions.SessionState{ - AccessToken: "AccessToken", - RefreshToken: "RefreshToken", - Email: "blah@blah.com", - RefreshDeadline: time.Now().Add(10 * time.Second), - }}, - sessions.MockCSRFStore{ - ResponseCSRF: "csrf", - Cookie: &http.Cookie{Value: "nonce"}}, - "", - true, - }, - {"failed email validation", - http.MethodGet, - "", - "code", - base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")), - []string{"pomerium.io"}, - falseValidator, - &sessions.MockSessionStore{}, + &sessions.MockSessionStore{SaveError: errors.New("error")}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ AccessToken: "AccessToken", @@ -736,7 +664,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")), []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ @@ -758,7 +686,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "", base64.URLEncoding.EncodeToString([]byte("nonce:https://corp.pomerium.io")), []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ @@ -780,7 +708,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", "nonce:https://corp.pomerium.io", []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ @@ -802,7 +730,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", base64.URLEncoding.EncodeToString([]byte("nonce")), []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ @@ -824,7 +752,7 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { "code", base64.URLEncoding.EncodeToString([]byte("nonce:corp.pomerium.io")), []string{"pomerium.io"}, - trueValidator, + &sessions.MockSessionStore{}, identity.MockProvider{ AuthenticateResponse: sessions.SessionState{ @@ -848,7 +776,6 @@ func TestAuthenticate_getOAuthCallback(t *testing.T) { csrfStore: tt.csrfStore, provider: tt.provider, ProxyRootDomains: tt.validDomains, - Validator: tt.validator, } u, _ := url.Parse("/oauthGet") params, _ := url.ParseQuery(u.RawQuery) diff --git a/authorize/authorize.go b/authorize/authorize.go new file mode 100644 index 000000000..e2ad41dc1 --- /dev/null +++ b/authorize/authorize.go @@ -0,0 +1,107 @@ +package authorize // import "github.com/pomerium/pomerium/authorize" + +import ( + "encoding/base64" + "errors" + "fmt" + + "github.com/pomerium/envconfig" + "github.com/pomerium/pomerium/internal/policy" +) + +// Options contains configuration settings for the authorize service. +type Options struct { + // SharedKey is used to validate requests between services + SharedKey string `envconfig:"SHARED_SECRET" required:"true"` + + // Policy is a base64 encoded yaml blob which enumerates + // per-route access control policies. + Policy string `envconfig:"POLICY"` + PolicyFile string `envconfig:"POLICY_FILE"` +} + +// OptionsFromEnvConfig creates an authorize service options from environmental +// variables. +func OptionsFromEnvConfig() (*Options, error) { + o := new(Options) + if err := envconfig.Process("", o); err != nil { + return nil, err + } + return o, nil +} + +// Validate checks to see if configuration values are valid for the +// authorize service. Returns first error, if found. +func (o *Options) Validate() error { + decoded, err := base64.StdEncoding.DecodeString(o.SharedKey) + if err != nil { + return fmt.Errorf("authorize: `SHARED_SECRET` setting is invalid base64: %v", err) + } + if len(decoded) != 32 { + return fmt.Errorf("authorize: `SHARED_SECRET` want 32 but got %d bytes", len(decoded)) + } + if o.Policy == "" && o.PolicyFile == "" { + return errors.New("authorize: either `POLICY` or `POLICY_FILE` must be non-nil") + } + if o.Policy != "" { + confBytes, err := base64.StdEncoding.DecodeString(o.Policy) + if err != nil { + return fmt.Errorf("authorize: `POLICY` is invalid base64 %v", err) + } + _, err = policy.FromConfig(confBytes) + if err != nil { + return fmt.Errorf("authorize: `POLICY` %v", err) + } + } + if o.PolicyFile != "" { + _, err = policy.FromConfigFile(o.PolicyFile) + if err != nil { + return fmt.Errorf("authorize: `POLICY_FILE` %v", err) + } + } + return nil +} + +// Authorize struct holds +type Authorize struct { + SharedKey string + + identityAccess IdentityValidator + // contextValidator + // deviceValidator +} + +// New validates and creates a new Authorize service from a set of Options +func New(opts *Options) (*Authorize, error) { + if opts == nil { + return nil, errors.New("authorize: options cannot be nil") + } + if err := opts.Validate(); err != nil { + return nil, err + } + // errors handled by validate + sharedKey, _ := base64.StdEncoding.DecodeString(opts.SharedKey) + var policies []policy.Policy + if opts.Policy != "" { + confBytes, _ := base64.StdEncoding.DecodeString(opts.Policy) + policies, _ = policy.FromConfig(confBytes) + } else { + policies, _ = policy.FromConfigFile(opts.PolicyFile) + } + + return &Authorize{ + SharedKey: string(sharedKey), + identityAccess: NewIdentityWhitelist(policies), + }, nil +} + +// ValidIdentity returns if an identity is authorized to access a route resource. +func (a *Authorize) ValidIdentity(route string, identity *Identity) bool { + return a.identityAccess.Valid(route, identity) +} + +// NewIdentityWhitelist returns an indentity validator. +// todo(bdd) : a radix-tree implementation is probably more efficient +func NewIdentityWhitelist(policies []policy.Policy) IdentityValidator { + return newIdentityWhitelistMap(policies) +} diff --git a/authorize/authorize_test.go b/authorize/authorize_test.go new file mode 100644 index 000000000..224cfc3ed --- /dev/null +++ b/authorize/authorize_test.go @@ -0,0 +1,93 @@ +package authorize + +import ( + "io/ioutil" + "log" + "os" + "reflect" + "testing" +) + +func TestOptionsFromEnvConfig(t *testing.T) { + t.Parallel() + os.Clearenv() + tests := []struct { + name string + want *Options + envKey string + envValue string + wantErr bool + }{ + {"shared secret missing", nil, "", "", true}, + {"with secret", &Options{SharedKey: "aGkK"}, "SHARED_SECRET", "aGkK", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envKey != "" { + os.Setenv(tt.envKey, tt.envValue) + defer os.Unsetenv(tt.envKey) + } + got, err := OptionsFromEnvConfig() + if (err != nil) != tt.wantErr { + t.Errorf("OptionsFromEnvConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("OptionsFromEnvConfig() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNew(t *testing.T) { + t.Parallel() + content := []byte(`[{"from": "pomerium.io","to":"httpbin.org"}]`) + tmpfile, err := ioutil.TempFile("", "example") + if err != nil { + log.Fatal(err) + } + defer os.Remove(tmpfile.Name()) // clean up + + if _, err := tmpfile.Write(content); err != nil { + log.Fatal(err) + } + if err := tmpfile.Close(); err != nil { + log.Fatal(err) + } + tests := []struct { + name string + SharedKey string + Policy string + PolicyFile string + wantErr bool + }{ + {"good", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", "WwogIHsKICAgICJyb3V0ZXMiOiAiaHR0cDovL3BvbWVyaXVtLmlvIgogIH0KXQ==", "", false}, + {"bad shared secret", "AZA85podM73CjLCjViDNz1EUvvejKpWp7Hysr0knXA==", "WwogIHsKICAgICJyb3V0ZXMiOiAiaHR0cDovL3BvbWVyaXVtLmlvIgogIH0KXQ==", "", true}, + {"really bad shared secret", "sup", "WwogIHsKICAgICJyb3V0ZXMiOiAiaHR0cDovL3BvbWVyaXVtLmlvIgogIH0KXQ==", "", true}, + {"bad base64 policy", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", "WwogIHsKICAgICJyb3V0ZXMiOiAiaHR0cDovL3BvbWVyaXVtLmlvIgogIH0KXQ^=", "", true}, + {"bad json", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", "e30=", "", true}, + {"no policies", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", "", "", true}, + {"good policy file", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", "", "./testdata/basic.json", true}, + {"bad policy file, directory", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", "", "./testdata/", true}, + {"good policy", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", "WwogIHsKICAgICJyb3V0ZXMiOiAiaHR0cDovL3BvbWVyaXVtLmlvIgogIH0KXQ==", "", false}, + {"good file", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", "", tmpfile.Name(), false}, + {"validation error, short secret", "AZA85podM73CjLCjViDNz1EUvvejKpWp7Hysr0knXA==", "", "", true}, + {"nil options", "", "", "", true}, // special case + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &Options{SharedKey: tt.SharedKey, Policy: tt.Policy, PolicyFile: tt.PolicyFile} + if tt.name == "nil options" { + o = nil + } + _, err := New(o) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + // if !reflect.DeepEqual(got, tt.want) { + // t.Errorf("New() = %v, want %v", got, tt.want) + // } + }) + } +} diff --git a/authorize/gprc.go b/authorize/gprc.go new file mode 100644 index 000000000..d7813d4de --- /dev/null +++ b/authorize/gprc.go @@ -0,0 +1,18 @@ +//go:generate protoc -I ../proto/authorize --go_out=plugins=grpc:../proto/authorize ../proto/authorize/authorize.proto + +package authorize // import "github.com/pomerium/pomerium/authorize" +import ( + "context" + + pb "github.com/pomerium/pomerium/proto/authorize" + + "github.com/pomerium/pomerium/internal/log" +) + +// Authorize validates the user identity, device, and context of a request for +// a given route. Currently only checks identity. +func (a *Authorize) Authorize(ctx context.Context, in *pb.AuthorizeRequest) (*pb.AuthorizeReply, error) { + ok := a.ValidIdentity(in.Route, &Identity{in.User, in.Email, in.Groups}) + log.Debug().Str("route", in.Route).Strs("groups", in.Groups).Str("email", in.Email).Bool("Valid?", ok).Msg("authorize/grpc") + return &pb.AuthorizeReply{IsValid: ok}, nil +} diff --git a/authorize/gprc_test.go b/authorize/gprc_test.go new file mode 100644 index 000000000..e4c775cd3 --- /dev/null +++ b/authorize/gprc_test.go @@ -0,0 +1,39 @@ +//go:generate protoc -I ../proto/authorize --go_out=plugins=grpc:../proto/authorize ../proto/authorize/authorize.proto + +package authorize + +import ( + "context" + "reflect" + "testing" + + pb "github.com/pomerium/pomerium/proto/authorize" +) + +func TestAuthorize_Authorize(t *testing.T) { + t.Parallel() + tests := []struct { + name string + SharedKey string + identityAccess IdentityValidator + in *pb.AuthorizeRequest + want *pb.AuthorizeReply + wantErr bool + }{ + {"valid authorization request", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", &MockIdentityValidator{ValidResponse: true}, &pb.AuthorizeRequest{Route: "http://pomerium.io", User: "user@pomerium.io"}, &pb.AuthorizeReply{IsValid: true}, false}, + {"invalid authorization request", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", &MockIdentityValidator{ValidResponse: false}, &pb.AuthorizeRequest{Route: "http://pomerium.io", User: "user@pomerium.io"}, &pb.AuthorizeReply{IsValid: false}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &Authorize{SharedKey: tt.SharedKey, identityAccess: tt.identityAccess} + got, err := a.Authorize(context.Background(), tt.in) + if (err != nil) != tt.wantErr { + t.Errorf("Authorize.Authorize() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Authorize.Authorize() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/authorize/identity.go b/authorize/identity.go new file mode 100644 index 000000000..31e3ab96d --- /dev/null +++ b/authorize/identity.go @@ -0,0 +1,127 @@ +package authorize // import "github.com/pomerium/pomerium/authorize" + +import ( + "fmt" + "strings" + "sync" + + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/policy" +) + +// Identity contains a user's identity information. +type Identity struct { + User string + Email string + Groups []string +} + +// EmailDomain returns the domain of the identity's email. +func (i *Identity) EmailDomain() string { + if i.Email == "" { + return "" + } + comp := strings.Split(i.Email, "@") + if len(comp) != 2 || comp[0] == "" { + return "" + } + return comp[1] +} + +// IdentityValidator provides an interface to check whether a user has access +// to a given route. +type IdentityValidator interface { + Valid(string, *Identity) bool +} + +type identityWhitelist struct { + sync.RWMutex + m map[string]bool +} + +// newIdentityWhitelistMap takes a slice of policies and creates a hashmap of identity +// authorizations per-route for each allowed group, domain, and email. +func newIdentityWhitelistMap(policies []policy.Policy) *identityWhitelist { + var im identityWhitelist + im.m = make(map[string]bool, len(policies)*3) + for _, p := range policies { + for _, group := range p.AllowedGroups { + log.Debug().Str("route", p.From).Str("group", group).Msg("add group") + im.PutGroup(p.From, group) + } + for _, domain := range p.AllowedDomains { + im.PutDomain(p.From, domain) + log.Debug().Str("route", p.From).Str("group", domain).Msg("add domain") + + } + for _, email := range p.AllowedEmails { + im.PutEmail(p.From, email) + log.Debug().Str("route", p.From).Str("group", email).Msg("add email") + } + } + return &im +} + +// Valid reports whether an identity has valid access for a given route. +func (m *identityWhitelist) Valid(route string, i *Identity) bool { + if ok := m.Domain(route, i.EmailDomain()); ok { + return ok + } + if ok := m.Email(route, i.Email); ok { + return ok + } + for _, group := range i.Groups { + if ok := m.Group(route, group); ok { + return ok + } + } + return false +} + +// Group retrieves per-route access given a group name. +func (m *identityWhitelist) Group(route, group string) bool { + m.RLock() + defer m.RUnlock() + return m.m[fmt.Sprintf("%s|group:%s", route, group)] +} + +// PutGroup adds an access entry for a route given a group name. +func (m *identityWhitelist) PutGroup(route, group string) { + m.Lock() + m.m[fmt.Sprintf("%s|group:%s", route, group)] = true + m.Unlock() +} + +// Domain retrieves per-route access given a domain name. +func (m *identityWhitelist) Domain(route, domain string) bool { + m.RLock() + defer m.RUnlock() + return m.m[fmt.Sprintf("%s|domain:%s", route, domain)] +} + +// PutDomain adds an access entry for a route given a domain name. +func (m *identityWhitelist) PutDomain(route, domain string) { + m.Lock() + m.m[fmt.Sprintf("%s|domain:%s", route, domain)] = true + m.Unlock() +} + +// Email retrieves per-route access given a user's email. +func (m *identityWhitelist) Email(route, email string) bool { + m.RLock() + defer m.RUnlock() + return m.m[fmt.Sprintf("%s|email:%s", route, email)] +} + +// PutEmail adds an access entry for a route given a user's email. +func (m *identityWhitelist) PutEmail(route, email string) { + m.Lock() + m.m[fmt.Sprintf("%s|email:%s", route, email)] = true + m.Unlock() +} + +// MockIdentityValidator is a mock implementation of IdentityValidator +type MockIdentityValidator struct{ ValidResponse bool } + +// Valid is a mock implementation IdentityValidator's Valid method +func (mv *MockIdentityValidator) Valid(u string, i *Identity) bool { return mv.ValidResponse } diff --git a/authorize/identity_test.go b/authorize/identity_test.go new file mode 100644 index 000000000..1babb88d9 --- /dev/null +++ b/authorize/identity_test.go @@ -0,0 +1,62 @@ +package authorize + +import ( + "testing" + + "github.com/pomerium/pomerium/internal/policy" +) + +func TestIdentity_EmailDomain(t *testing.T) { + t.Parallel() + tests := []struct { + name string + Email string + want string + }{ + {"simple", "user@pomerium.io", "pomerium.io"}, + {"period malformed", "user@.io", ".io"}, + {"empty", "", ""}, + {"empty first part", "@uhoh.com", ""}, + {"empty second part", "uhoh@", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := &Identity{Email: tt.Email} + if got := i.EmailDomain(); got != tt.want { + t.Errorf("Identity.EmailDomain() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_IdentityWhitelistMap(t *testing.T) { + t.Parallel() + tests := []struct { + name string + policies []policy.Policy + route string + Identity *Identity + want bool + }{ + {"valid domain", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "user@example.com"}, true}, + {"invalid domain prepend", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "a@1example.com"}, false}, + {"invalid domain postpend", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "user@example.com2"}, false}, + {"valid group", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{"admin"}}, true}, + {"invalid group", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{"everyone"}}, false}, + {"invalid empty", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{""}}, false}, + {"valid group multiple", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{"everyone", "admin"}}, true}, + {"invalid group multiple", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{"everyones", "sadmin"}}, false}, + {"valid user email", []policy.Policy{{From: "example.com", AllowedEmails: []string{"user@example.com"}}}, "example.com", &Identity{Email: "user@example.com"}, true}, + {"invalid user email", []policy.Policy{{From: "example.com", AllowedEmails: []string{"user@example.com"}}}, "example.com", &Identity{Email: "user2@example.com"}, false}, + {"empty everything", []policy.Policy{{From: "example.com"}}, "example.com", &Identity{Email: "user2@example.com"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wl := NewIdentityWhitelist(tt.policies) + if got := wl.Valid(tt.route, tt.Identity); got != tt.want { + t.Errorf("IdentityACLMap.Allowed() = %v, want %v", got, tt.want) + } + + }) + } +} diff --git a/cmd/pomerium/main.go b/cmd/pomerium/main.go index 38373e217..c99c6fe47 100644 --- a/cmd/pomerium/main.go +++ b/cmd/pomerium/main.go @@ -9,12 +9,13 @@ import ( "google.golang.org/grpc" "github.com/pomerium/pomerium/authenticate" + "github.com/pomerium/pomerium/authorize" "github.com/pomerium/pomerium/internal/https" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/middleware" - "github.com/pomerium/pomerium/internal/options" "github.com/pomerium/pomerium/internal/version" - pb "github.com/pomerium/pomerium/proto/authenticate" + pbAuthenticate "github.com/pomerium/pomerium/proto/authenticate" + pbAuthorize "github.com/pomerium/pomerium/proto/authorize" "github.com/pomerium/pomerium/proxy" ) @@ -36,7 +37,7 @@ func main() { fmt.Printf("%s", version.FullVersion()) os.Exit(0) } - log.Info().Str("version", version.FullVersion()).Str("service-mode", mainOpts.Services).Msg("cmd/pomerium") + log.Info().Str("version", version.FullVersion()).Str("service", mainOpts.Services).Msg("cmd/pomerium") grpcAuth := middleware.NewSharedSecretCred(mainOpts.SharedKey) grpcOpts := []grpc.ServerOption{grpc.UnaryInterceptor(grpcAuth.ValidateRequest)} @@ -45,22 +46,29 @@ func main() { var authenticateService *authenticate.Authenticate var authHost string if mainOpts.Services == "all" || mainOpts.Services == "authenticate" { - authOpts, err := authenticate.OptionsFromEnvConfig() + opts, err := authenticate.OptionsFromEnvConfig() if err != nil { log.Fatal().Err(err).Msg("cmd/pomerium: authenticate settings") } - emailValidator := func(p *authenticate.Authenticate) error { - p.Validator = options.NewEmailValidator(authOpts.AllowedDomains) - return nil - } - - authenticateService, err = authenticate.New(authOpts, emailValidator) + authenticateService, err = authenticate.New(opts) if err != nil { log.Fatal().Err(err).Msg("cmd/pomerium: new authenticate") } - authHost = authOpts.RedirectURL.Host - pb.RegisterAuthenticatorServer(grpcServer, authenticateService) + authHost = opts.RedirectURL.Host + pbAuthenticate.RegisterAuthenticatorServer(grpcServer, authenticateService) + } + var authorizeService *authorize.Authorize + if mainOpts.Services == "all" || mainOpts.Services == "authorize" { + opts, err := authorize.OptionsFromEnvConfig() + if err != nil { + log.Fatal().Err(err).Msg("cmd/pomerium: authorize settings") + } + authorizeService, err = authorize.New(opts) + if err != nil { + log.Fatal().Err(err).Msg("cmd/pomerium: new authorize") + } + pbAuthorize.RegisterAuthorizerServer(grpcServer, authorizeService) } var proxyService *proxy.Proxy @@ -74,7 +82,10 @@ func main() { if err != nil { log.Fatal().Err(err).Msg("cmd/pomerium: new proxy") } + // cleanup our RPC services defer proxyService.AuthenticateClient.Close() + defer proxyService.AuthorizeClient.Close() + } topMux := http.NewServeMux() diff --git a/cmd/pomerium/options.go b/cmd/pomerium/options.go index e617b4001..d3fdb27a0 100644 --- a/cmd/pomerium/options.go +++ b/cmd/pomerium/options.go @@ -64,6 +64,7 @@ func isValidService(service string) bool { case "all", "proxy", + "authorize", "authenticate": return true } diff --git a/cmd/pomerium/options_test.go b/cmd/pomerium/options_test.go index 3433d31f1..acd594744 100644 --- a/cmd/pomerium/options_test.go +++ b/cmd/pomerium/options_test.go @@ -55,7 +55,7 @@ func Test_isValidService(t *testing.T) { {"all", "all", true}, {"authenticate", "authenticate", true}, {"authenticate bad case", "AuThenticate", false}, - {"authorize not yet implemented", "authorize", false}, + {"authorize implemented", "authorize", true}, {"jiberish", "xd23", false}, } for _, tt := range tests { diff --git a/docs/docs/config-reference.md b/docs/docs/config-reference.md index 5a6ec7437..ccffd04fa 100644 --- a/docs/docs/config-reference.md +++ b/docs/docs/config-reference.md @@ -19,7 +19,7 @@ Global settings are configuration variables that are shared by all services. - Environmental Variable: `SERVICES` - Type: `string` - Default: `all` -- Options: `all` `authenticate` or `proxy` +- Options: `all` `authenticate` `authorize` or `proxy` Service mode sets the pomerium service(s) to run. If testing, you may want to set to `all` and run pomerium in "all-in-one mode." In production, you'll likely want to spin of several instances of each service mode for high availability. @@ -43,6 +43,17 @@ Shared Secret is the base64 encoded 256-bit key used to mutually authenticate re head -c32 /dev/urandom | base64 ``` +### Policy + +- Environmental Variable: either `POLICY` or `POLICY_FILE` +- Type: [base64 encoded] `string` or relative file location +- Filetype: `json` or `yaml` +- Required + +Policy contains the routes, and their access policies. For example, + +<<< @/policy.example.yaml + ### Debug - Environmental Variable: `POMERIUM_DEBUG` @@ -51,7 +62,7 @@ head -c32 /dev/urandom | base64 By default, JSON encoded logs are produced. Debug enables colored, human-readable, and more verbose logs to be streamed to [standard out](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout)). In production, it's recommended to be set to `false`. -For example, if true. +For example, if `true`. ``` 10:37AM INF cmd/pomerium version=v0.0.1-dirty+ede4124 @@ -60,7 +71,7 @@ For example, if true. 10:37AM INF proxy/authenticator: grpc connection OverrideCertificateName= addr=auth.corp.beyondperimeter.com:443 ``` -If false: +If `false`: ``` {"level":"info","version":"v0.0.1-dirty+ede4124","time":"2019-02-18T10:41:03-08:00","message":"cmd/pomerium"} @@ -96,21 +107,6 @@ Certificate key is the x509 _private-key_ used to establish secure HTTP and gRPC Redirect URL is the url the user will be redirected to following authentication with the third-party identity provider (IdP). Note the URL ends with `/oauth2/callback`. This setting will mirror the URL set when configuring your [identity provider]. Typically, on the provider side, this is called an _authorized callback url_. -### Allowed Email Domains - -::: warning -This setting will be deprecated with the upcoming release of the authorization service. -::: - -- Environmental Variable: `ALLOWED_DOMAINS` -- Type: `[]string` (e.g. comma separated list of strings) -- Required -- Example: `engineering.corp-b.com,devops.corp-a.com` - -The allowed email domains settings dictates which email domains are valid for access. If an authenticated user has a email ending in a non-whitelisted domain, the request will be denied as unauthorized. - - - ### Proxy Root Domains - Environmental Variable: `PROXY_ROOT_DOMAIN` @@ -126,7 +122,7 @@ Proxy Root Domains specifies the sub-domains that can proxy requests. For exampl - Required - Options: `azure` `google` `okta` `gitlab` `onelogin` or `oidc` -Provider is the short-hand name of a built-in OpenID Connect (oidc) identity provider to be used for authentication. To use a generic provider,set to `oidc`. +Provider is the short-hand name of a built-in OpenID Connect (oidc) identity provider to be used for authentication. To use a generic provider,set to `oidc`. See [identity provider] for details. @@ -161,19 +157,18 @@ Provider URL is the base path to an identity provider's [OpenID connect discover - Default: `oidc`,`profile`, `email`, `offline_access` (typically) - Optional for built-in identity providers. -Identity provider scopes correspond to access privilege scopes as defined in Section 3.3 of OAuth 2.0 RFC6749. The scopes associated with Access Tokens determine what resources will be available when they are used to access OAuth 2.0 protected endpoints. If you are using a built-in provider, you probably don't want to set customized scopes. +Identity provider scopes correspond to access privilege scopes as defined in Section 3.3 of OAuth 2.0 RFC6749\. The scopes associated with Access Tokens determine what resources will be available when they are used to access OAuth 2.0 protected endpoints. If you are using a built-in provider, you probably don't want to set customized scopes. + +### Identity Provider Service Account + +- Environmental Variable: `IDP_SERVICE_ACCOUNT` +- Type: `string` +- Required, depending on provider + +Identity Provider Service Account is field used to configure any additional user account or access-token that may be required for querying additional user information during authentication. For a concrete example, Google an additional service account and to make a follow-up request to query a user's group membership. For more information, refer to the [identity provider] docs to see if your provider requires this setting. ## Proxy Service -### Routes - -- Environmental Variable: `ROUTES` -- Type: `map[string]string` comma separated mapping of managed entities. -- Required -- Example: `https://httpbin.corp.example.com=http://httpbin,https://hello.corp.example.com=http://hello:8080/` - -The routes setting contains a mapping of routes to be managed by pomerium. - ### Signing Key - Environmental Variable: `SIGNING_KEY` @@ -198,17 +193,25 @@ Authenticate Service URL is the externally accessible URL for the authenticate s - Optional - Example: `pomerium-authenticate-service.pomerium.svc.cluster.local` -Authenticate Internal Service URL is the internal location of the authenticate service. This setting is used to override the authenticate service url for when you need to do "behind-the-ingress" inter-service communication. This is typically required for ingresses and load balancers that do not support HTTP/2 or gRPC termination. +Authenticate Internal Service URL is the internally routed dns name of the authenticate service. This setting is used to override the authenticate service url for when you need to do "behind-the-ingress" inter-service communication. This is typically required for ingresses and load balancers that do not support HTTP/2 or gRPC termination. -### Authenticate Service Port +### Authorize Service URL -- Environmental Variable: `AUTHENTICATE_SERVICE_PORT` -- Type: `int` +- Environmental Variable: `AUTHORIZE_SERVICE_URL` +- Type: `URL` +- Required +- Example: `https://access.corp.example.com` + +Authorize Service URL is the externally accessible URL for the authorize service. + +### Authorize Internal Service URL + +- Environmental Variable: `AUTHORIZE_INTERNAL_URL` +- Type: `string` - Optional -- Default: `443` -- Example: `8443` +- Example: `pomerium-authorize-service.pomerium.svc.cluster.local` -Authenticate Service Port is used to set the port value for authenticate service communication. +Authorize Internal Service URL is the internally routed dns name of the authorize service. This setting is used to override the authorize service url for when you need to do "behind-the-ingress" inter-service communication. This is typically required for ingresses and load balancers that do not support HTTP/2 or gRPC termination. ### Override Certificate Name @@ -217,7 +220,7 @@ Authenticate Service Port is used to set the port value for authenticate service - Optional (but typically required if Authenticate Internal Service Address is set) - Example: `*.corp.example.com` if wild card or `authenticate.corp.example.com` -When Authenticate Internal Service Address is set, secure service communication can fail because the external certificate name will not match the internally routed service url. This setting allows you to override that check. +When Authenticate Internal Service Address is set, secure service communication can fail because the external certificate name will not match the internally routed service url. This setting allows you to override that check. ### Certificate Authority diff --git a/docs/docs/examples.md b/docs/docs/examples.md index 11e4d7cda..91eb0dfce 100644 --- a/docs/docs/examples.md +++ b/docs/docs/examples.md @@ -56,20 +56,6 @@ Customize for your identity provider run `docker-compose up -f nginx.docker-comp <<< @/docs/docs/examples/docker/nginx.docker-compose.yml -### Gitlab On-Prem - -- Docker and Docker-Compose based. -- Uses pre-configured built-in nginx load balancer -- Runs separate containers for each service -- Comes with a pre-configured instance of on-prem Gitlab-CE -- Routes default to on-prem [helloworld], [httpbin], and [gitlab]. - -Customize for your identity provider run `docker-compose up -f gitlab.docker-compose.yml` - -#### gitlab.docker-compose.yml - -<<< @/docs/docs/examples/docker/gitlab.docker-compose.yml - ## Kubernetes ### Google Kubernetes Engine @@ -103,7 +89,6 @@ Customize for your identity provider run `docker-compose up -f gitlab.docker-com <<< @/docs/docs/examples/kubernetes/ingress.yml -[gitlab]: https://docs.gitlab.com/ee/user/project/container_registry.html [helloworld]: https://hub.docker.com/r/tutum/hello-world [httpbin]: https://httpbin.org/ [https load balancing]: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress diff --git a/docs/docs/examples/docker/basic.docker-compose.yml b/docs/docs/examples/docker/basic.docker-compose.yml index 9e3d2ec42..8da3dd65f 100644 --- a/docs/docs/examples/docker/basic.docker-compose.yml +++ b/docs/docs/examples/docker/basic.docker-compose.yml @@ -1,43 +1,35 @@ # Example Pomerium configuration. # # NOTE! Change IDP_* settings to match your identity provider settings! -# NOTE! Generate new SHARED_SECRET and COOKIE_SECRET keys! +# NOTE! Generate new SHARED_SECRET and COOKIE_SECRET keys! e.g. `head -c32 /dev/urandom | base64` # NOTE! Replace `corp.beyondperimeter.com` with whatever your domain is # NOTE! Make sure certificate files (cert.pem/privkey.pem) are in the same directory as this file -# NOTE! Wrap URLs in quotes to avoid parse errors +# NOTE! Make sure your policy file (policy.example.yaml) is in the same directory as this file + version: "3" services: - pomerium-all: + pomerium: image: pomerium/pomerium:latest # or `build: .` to build from source environment: + - POMERIUM_DEBUG=true - SERVICES=all - # auth settings - REDIRECT_URL=https://auth.corp.beyondperimeter.com/oauth2/callback - # Identity Provider Settings (Must be changed!) - IDP_PROVIDER=google - IDP_PROVIDER_URL=https://accounts.google.com - IDP_CLIENT_ID=REPLACE_ME.apps.googleusercontent.com - IDP_CLIENT_SECRET=REPLACE_ME - # - SCOPE="openid email" - PROXY_ROOT_DOMAIN=beyondperimeter.com - - ALLOWED_DOMAINS=* - # shared service settings - # Generate 256 bit random keys e.g. `head -c32 /dev/urandom | base64` - SHARED_SECRET=aDducXQzK2tPY3R4TmdqTGhaYS80eGYxcTUvWWJDb2M= - COOKIE_SECRET=V2JBZk0zWGtsL29UcFUvWjVDWWQ2UHExNXJ0b2VhcDI= - # proxy settings + - CERTIFICATE_FILE=cert.pem + - CERTIFICATE_KEY_FILE=privkey.pem - AUTHENTICATE_SERVICE_URL=https://auth.corp.beyondperimeter.com - - ROUTES=https://httpbin.corp.beyondperimeter.com=http://httpbin,https://helloworld.corp.beyondperimeter.com=http://helloworld:8080/ - # - SIGNING_KEY=LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU0zbXBaSVdYQ1g5eUVneFU2czU3Q2J0YlVOREJTQ0VBdFFGNWZVV0hwY1FvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFaFBRditMQUNQVk5tQlRLMHhTVHpicEVQa1JyazFlVXQxQk9hMzJTRWZVUHpOaTRJV2VaLwpLS0lUdDJxMUlxcFYyS01TYlZEeXI5aWp2L1hoOThpeUV3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= - # if passing certs as files - # - CERTIFICATE_KEY=corp.beyondperimeter.com.crt - # - CERTIFICATE_KEY_FILE=corp.beyondperimeter.com.key - # Or, you can pass certifcates as bas64 encoded values. e.g. `base64 -i cert.pem` - # - CERTIFICATE= - # - CERTIFICATE_KEY= - volumes: # volumes is optional; used if passing certificates as files + - AUTHORIZE_SERVICE_URL=https://access.corp.beyondperimeter.com + - POLICY_FILE=./policy.yaml + volumes: - ./cert.pem:/pomerium/cert.pem:ro - ./privkey.pem:/pomerium/privkey.pem:ro + - ./policy.example.yaml:/pomerium/policy.yaml:ro ports: - 443:443 @@ -46,9 +38,8 @@ services: image: kennethreitz/httpbin:latest expose: - 80 - - # https://helloworld.corp.beyondperimeter.com - helloworld: + # https://hello.corp.beyondperimeter.com + hello: image: gcr.io/google-samples/hello-app:1.0 expose: - 8080 diff --git a/docs/docs/examples/docker/gitlab.docker-compose.yml b/docs/docs/examples/docker/gitlab.docker-compose.yml deleted file mode 100644 index 51b2755ad..000000000 --- a/docs/docs/examples/docker/gitlab.docker-compose.yml +++ /dev/null @@ -1,94 +0,0 @@ -version: "3" - -services: - nginx: - image: pomerium/nginx-proxy:latest - ports: - - "443:443" - volumes: - # NOTE!!! : nginx must be supplied with your wildcard certificates. And it expects - # it in the format of whatever your wildcard domain name is in. - # see : https://github.com/jwilder/nginx-proxy#wildcard-certificates - # So, if your subdomain is corp.beyondperimeter.com, you'd have the following : - - ./cert.pem:/etc/nginx/certs/corp.beyondperimeter.com.crt:ro - - ./privkey.pem:/etc/nginx/certs/corp.beyondperimeter.com.key:ro - - /var/run/docker.sock:/tmp/docker.sock:ro - - pomerium-authenticate: - build: . - restart: always - environment: - - POMERIUM_DEBUG=true - - SERVICES=authenticate - # auth settings - - REDIRECT_URL=https://auth.corp.beyondperimeter.com/oauth2/callback - # Identity Provider Settings (Must be changed!) - - IDP_PROVIDER=google - - IDP_PROVIDER_URL=https://accounts.google.com - - IDP_CLIENT_ID=REPLACEME - - IDP_CLIENT_SECRET=REPLACE_ME - - PROXY_ROOT_DOMAIN=corp.beyondperimeter.com - - ALLOWED_DOMAINS=* - - SKIP_PROVIDER_BUTTON=false - # shared service settings - # Generate 256 bit random keys e.g. `head -c32 /dev/urandom | base64` - - SHARED_SECRET=aDducXQzK2tPY3R4TmdqTGhaYS80eGYxcTUvWWJDb2M= - - COOKIE_SECRET=V2JBZk0zWGtsL29UcFUvWjVDWWQ2UHExNXJ0b2VhcDI= - - VIRTUAL_PROTO=https - - VIRTUAL_HOST=auth.corp.beyondperimeter.com - - VIRTUAL_PORT=443 - volumes: # volumes is optional; used if passing certificates as files - - ./cert.pem:/pomerium/cert.pem:ro - - ./privkey.pem:/pomerium/privkey.pem:ro - expose: - - 443 - pomerium-proxy: - build: . - restart: always - environment: - - POMERIUM_DEBUG=true - - SERVICES=proxy - # proxy settings - - AUTHENTICATE_SERVICE_URL=https://auth.corp.beyondperimeter.com - # IMPORTANT! If you are running pomerium behind another ingress (loadbalancer/firewall/etc) - # you must tell pomerium proxy how to communicate using an internal hostname for RPC - - AUTHENTICATE_INTERNAL_URL=pomerium-authenticate:443 - # When communicating internally, rPC is going to get a name conflict expecting an external - # facing certificate name (i.e. authenticate-service.local vs *.corp.example.com). - - OVERRIDE_CERTIFICATE_NAME=*.corp.beyondperimeter.com - - ROUTES=https://gitlab.corp.beyondperimeter.com=https://gitlab - # Generate 256 bit random keys e.g. `head -c32 /dev/urandom | base64` - - SHARED_SECRET=aDducXQzK2tPY3R4TmdqTGhaYS80eGYxcTUvWWJDb2M= - - COOKIE_SECRET=V2JBZk0zWGtsL29UcFUvWjVDWWQ2UHExNXJ0b2VhcDI= - # nginx settings - - VIRTUAL_PROTO=https - - VIRTUAL_HOST=*.corp.beyondperimeter.com - - VIRTUAL_PORT=443 - volumes: # volumes is optional; used if passing certificates as files - - ./cert.pem:/pomerium/cert.pem:ro - - ./privkey.pem:/pomerium/privkey.pem:ro - expose: - - 443 - - gitlab: - hostname: gitlab.corp.beyondperimeter.com - image: gitlab/gitlab-ce:latest - restart: always - expose: - - 443 - - 80 - - 22 - environment: - GITLAB_OMNIBUS_CONFIG: | - external_url 'https://gitlab.corp.beyondperimeter.com' - nginx['ssl_certificate'] = '/etc/gitlab/trusted-certs/corp.beyondperimeter.com.crt' - nginx['ssl_certificate_key'] = '/etc/gitlab/trusted-certs/corp.beyondperimeter.com.key' - VIRTUAL_PROTO: https - VIRTUAL_HOST: gitlab.corp.beyondperimeter.com - VIRTUAL_PORT: 443 - volumes: - - ./cert.pem:/etc/gitlab/trusted-certs/corp.beyondperimeter.com.crt - - ./privkey.pem:/etc/gitlab/trusted-certs/corp.beyondperimeter.com.key - - $HOME/gitlab/config:/etc/gitlab - - $HOME/gitlab/logs:/var/log/gitlab - - $HOME/gitlab/data:/var/opt/gitlab diff --git a/docs/docs/examples/docker/nginx.docker-compose.yml b/docs/docs/examples/docker/nginx.docker-compose.yml index fb4f0403b..f00df1057 100644 --- a/docs/docs/examples/docker/nginx.docker-compose.yml +++ b/docs/docs/examples/docker/nginx.docker-compose.yml @@ -15,59 +15,75 @@ services: - /var/run/docker.sock:/tmp/docker.sock:ro pomerium-authenticate: - build: . + image: pomerium/pomerium:latest # or `build: .` to build from source restart: always environment: - POMERIUM_DEBUG=true - SERVICES=authenticate - # auth settings - REDIRECT_URL=https://auth.corp.beyondperimeter.com/oauth2/callback # Identity Provider Settings (Must be changed!) - IDP_PROVIDER=google - IDP_PROVIDER_URL=https://accounts.google.com - - IDP_CLIENT_ID=REPLACEME + - IDP_CLIENT_ID=REPLACE_ME.apps.googleusercontent.com - IDP_CLIENT_SECRET=REPLACE_ME - PROXY_ROOT_DOMAIN=corp.beyondperimeter.com - - ALLOWED_DOMAINS=* - - SKIP_PROVIDER_BUTTON=false - # shared service settings - # Generate 256 bit random keys e.g. `head -c32 /dev/urandom | base64` - SHARED_SECRET=aDducXQzK2tPY3R4TmdqTGhaYS80eGYxcTUvWWJDb2M= - COOKIE_SECRET=V2JBZk0zWGtsL29UcFUvWjVDWWQ2UHExNXJ0b2VhcDI= + # nginx settings - VIRTUAL_PROTO=https - VIRTUAL_HOST=auth.corp.beyondperimeter.com - VIRTUAL_PORT=443 - volumes: # volumes is optional; used if passing certificates as files + volumes: - ./cert.pem:/pomerium/cert.pem:ro - ./privkey.pem:/pomerium/privkey.pem:ro expose: - 443 - pomerium-proxy: - build: . - restart: always + pomerium-proxy: + image: pomerium/pomerium:latest # or `build: .` to build from source + restart: always environment: - POMERIUM_DEBUG=true - SERVICES=proxy - # proxy settings + - POLICY_FILE=policy.yaml - AUTHENTICATE_SERVICE_URL=https://auth.corp.beyondperimeter.com + - AUTHORIZE_SERVICE_URL=https://access.corp.beyondperimeter.com # IMPORTANT! If you are running pomerium behind another ingress (loadbalancer/firewall/etc) # you must tell pomerium proxy how to communicate using an internal hostname for RPC - AUTHENTICATE_INTERNAL_URL=pomerium-authenticate:443 + - AUTHORIZE_INTERNAL_URL=pomerium-authorize:443 # When communicating internally, rPC is going to get a name conflict expecting an external # facing certificate name (i.e. authenticate-service.local vs *.corp.example.com). - OVERRIDE_CERTIFICATE_NAME=*.corp.beyondperimeter.com - - ROUTES=https://httpbin.corp.beyondperimeter.com=http://httpbin,https://hello.corp.beyondperimeter.com=http://hello:8080/ - # Generate 256 bit random keys e.g. `head -c32 /dev/urandom | base64` - SHARED_SECRET=aDducXQzK2tPY3R4TmdqTGhaYS80eGYxcTUvWWJDb2M= - COOKIE_SECRET=V2JBZk0zWGtsL29UcFUvWjVDWWQ2UHExNXJ0b2VhcDI= # nginx settings - VIRTUAL_PROTO=https - VIRTUAL_HOST=*.corp.beyondperimeter.com - VIRTUAL_PORT=443 - volumes: # volumes is optional; used if passing certificates as files + volumes: - ./cert.pem:/pomerium/cert.pem:ro - ./privkey.pem:/pomerium/privkey.pem:ro + - ./policy.example.yaml:/pomerium/policy.yaml:ro + expose: + - 443 + + pomerium-authorize: + image: pomerium/pomerium:latest # or `build: .` to build from source + restart: always + environment: + - POMERIUM_DEBUG=true + - SERVICES=authorize + - POLICY_FILE=policy.yaml + - SHARED_SECRET=aDducXQzK2tPY3R4TmdqTGhaYS80eGYxcTUvWWJDb2M= + # nginx settings + - VIRTUAL_PROTO=https + - VIRTUAL_HOST=access.corp.beyondperimeter.com + - VIRTUAL_PORT=443 + volumes: + - ./cert.pem:/pomerium/cert.pem:ro + - ./privkey.pem:/pomerium/privkey.pem:ro + - ./policy.example.yaml:/pomerium/policy.yaml:ro expose: - 443 diff --git a/docs/docs/examples/kubernetes/ingress.nginx.yml b/docs/docs/examples/kubernetes/ingress.nginx.yml index a599e2c33..feea07f01 100644 --- a/docs/docs/examples/kubernetes/ingress.nginx.yml +++ b/docs/docs/examples/kubernetes/ingress.nginx.yml @@ -8,6 +8,7 @@ metadata: nginx.ingress.kubernetes.io/force-ssl-redirect: "true" nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" + # to avoid ingress routing, enable # nginx.ingress.kubernetes.io/ssl-passthrough: "true" spec: @@ -16,6 +17,8 @@ spec: hosts: - "*.corp.beyondperimeter.com" - "auth.corp.beyondperimeter.com" + - "access.corp.beyondperimeter.com" + rules: - host: "*.corp.beyondperimeter.com" http: @@ -32,3 +35,10 @@ spec: backend: serviceName: pomerium-authenticate-service servicePort: https + - host: "access.corp.beyondperimeter.com" + http: + paths: + - paths: + backend: + serviceName: pomerium-authorize-service + servicePort: https diff --git a/docs/docs/examples/kubernetes/ingress.yml b/docs/docs/examples/kubernetes/ingress.yml index 5d4a9d711..c6bba08d6 100644 --- a/docs/docs/examples/kubernetes/ingress.yml +++ b/docs/docs/examples/kubernetes/ingress.yml @@ -13,6 +13,8 @@ spec: hosts: - "*.corp.beyondperimeter.com" - "auth.corp.beyondperimeter.com" + - "access.corp.beyondperimeter.com" + rules: - host: "*.corp.beyondperimeter.com" http: @@ -29,3 +31,10 @@ spec: backend: serviceName: pomerium-authenticate-service servicePort: https + - host: "access.corp.beyondperimeter.com" + http: + paths: + - paths: + backend: + serviceName: pomerium-authorize-service + servicePort: https diff --git a/docs/docs/examples/kubernetes/proxy.deploy.yml b/docs/docs/examples/kubernetes/proxy.deploy.yml index 304dce8df..679c2d9b1 100644 --- a/docs/docs/examples/kubernetes/proxy.deploy.yml +++ b/docs/docs/examples/kubernetes/proxy.deploy.yml @@ -23,10 +23,12 @@ spec: name: https protocol: TCP env: - - name: ROUTES - value: https://httpbin.corp.beyondperimeter.com=https://httpbin.org,https://hi.corp.beyondperimeter.com=http://hello-app.pomerium.svc.cluster.local:8080 - name: SERVICES value: proxy + - name: AUTHORIZE_SERVICE_URL + value: https://access.corp.beyondperimeter.com + - name: AUTHORIZE_INTERNAL_URL + value: "pomerium-authorize-service.pomerium.svc.cluster.local" - name: AUTHENTICATE_SERVICE_URL value: https://auth.corp.beyondperimeter.com - name: AUTHENTICATE_INTERNAL_URL diff --git a/docs/docs/identity-providers.md b/docs/docs/identity-providers.md index b1755edf8..a9637c091 100644 --- a/docs/docs/identity-providers.md +++ b/docs/docs/identity-providers.md @@ -120,7 +120,7 @@ IDP_CLIENT_SECRET="REPLACE-ME" :::warning -Support was removed in v0.0.3 because Gitlab does not provide callers with a user email, under any scope, to a caller unless that user has selected her email to be public. Pomerium until [this gitlab bug](https://gitlab.com/gitlab-org/gitlab-ce/issues/44435#note_88150387) is fixed. +Support was removed in v0.0.3 because Gitlab does not provide callers with a user email, under any scope, to a caller unless that user has selected her email to be public. Pomerium support is blocked until [this gitlab bug](https://gitlab.com/gitlab-org/gitlab-ce/issues/44435#note_88150387) is fixed. ::: @@ -192,14 +192,16 @@ In order to have Pomerium validate group membership, we'll also need to configur ![Google create service account](./google/google-create-sa.png) -Then, you'll need to manually open an editor add an `impersonate_user` key to the downloaded public/private key file. In this case, we'd be impersonating the admin account `user@pomerium.io`. +Then, you'll need to manually open an editor and add an `impersonate_user` field to the downloaded public/private key file. In this case, we'd be impersonating the admin account `user@pomerium.io`. ::: warning -You MUST add the `impersonate_user` field to your json key file. [Google requires](https://stackoverflow.com/questions/48585700/is-it-possible-to-call-apis-from-service-account-without-acting-on-behalf-of-a-u/48601364#48601364) that service accounts act on behalf of another user. + [Google requires](https://stackoverflow.com/questions/48585700/is-it-possible-to-call-apis-from-service-account-without-acting-on-behalf-of-a-u/48601364#48601364) that service accounts act on behalf of another user. You MUST add the `impersonate_user` field to your json key file. ::: + + ```json { "type": "service_account", @@ -210,7 +212,7 @@ You MUST add the `impersonate_user` field to your json key file. [Google require } ``` -The base64 encoded contents of this public/private key pair json file will used for the value of the `IDP_SERVICE_ACCOUNT` configuration setting. +The base64 encoded contents of this public/private key pair json file will used for the value of the `IDP_SERVICE_ACCOUNT` configuration setting. Next we'll delegate G-suite group membership access to the service account we just created . diff --git a/docs/docs/readme.md b/docs/docs/readme.md index 3678a8bb2..d7eb7f3cf 100644 --- a/docs/docs/readme.md +++ b/docs/docs/readme.md @@ -6,7 +6,7 @@ Pomerium is an open-source, identity-aware access proxy. ## Why -Traditional [perimeter](https://www.redbooks.ibm.com/redpapers/pdfs/redp4397.pdf) [security](https://en.wikipedia.org/wiki/Perimeter_Security)has some shortcomings, namely: +Traditional [perimeter](https://www.redbooks.ibm.com/redpapers/pdfs/redp4397.pdf) [security](https://en.wikipedia.org/wiki/Perimeter_Security) has some shortcomings, namely: - Insider threat is not well addressed and 28% of breaches are [by internal actors](http://www.documentwereld.nl/files/2018/Verizon-DBIR_2018-Main_report.pdf). - Impenetrable fortress in theory falls in practice; multiple entry points (like VPNs), lots of firewall rules, network segmentation creep. diff --git a/docs/guide/from-source.md b/docs/guide/from-source.md index 5a266e710..0895b4fa8 100644 --- a/docs/guide/from-source.md +++ b/docs/guide/from-source.md @@ -27,17 +27,19 @@ The command will run all the tests, some code linters, then build the binary. If ## Configure -Make a copy of the [env.example] and name it something like `env`. +### Environmental Configuration Variables -```bash -cp env.example env -``` +Create a environmental configuration file modify its configuration to to match your [identity provider] settings. For example, `env`: -Modify your `env` configuration to to match your [identity provider] settings. +<<< @/env.example -```bash -vim env -``` + +### policy.yaml +Next, create a policy configuration file which will contain the routes you want to proxy, and their desired access-controls. For example, `policy.example.yaml`: + +<<< @/policy.example.yaml + +### Certificates Place your domain's wild-card TLS certificate next to the compose file. If you don't have one handy, the included [script] generates one from [LetsEncrypt]. diff --git a/docs/guide/kubernetes.md b/docs/guide/kubernetes.md index 6bdd0fb16..6f29a99a1 100644 --- a/docs/guide/kubernetes.md +++ b/docs/guide/kubernetes.md @@ -35,6 +35,8 @@ git clone https://github.com/pomerium/pomerium.git $HOME/pomerium Edit the the [example kubernetes files][./scripts/kubernetes_gke.sh] to match your [identity provider] settings: +- `./docs/docs/examples/authorize.deploy.yml` +- `./docs/docs/examples/authorize.service.yml` - `./docs/docs/examples/authenticate.deploy.yml` - `./docs/docs/examples/authenticate.service.yml` - `./docs/docs/examples/proxy.deploy.yml` @@ -50,8 +52,8 @@ Edit [./scripts/kubernetes_gke.sh] making sure to change the identity provider s Run [./scripts/kubernetes_gke.sh] which will: 1. Provision a new cluster -2. Create authenticate and proxy [deployments](https://cloud.google.com/kubernetes-engine/docs/concepts/deployment). -3. Provision and apply authenticate and proxy [services](https://cloud.google.com/kubernetes-engine/docs/concepts/service). +2. Create authenticate, authorize, and proxy [deployments](https://cloud.google.com/kubernetes-engine/docs/concepts/deployment). +3. Provision and apply authenticate, authorize, and proxy [services](https://cloud.google.com/kubernetes-engine/docs/concepts/service). 4. Configure an ingress load balancer. ```bash diff --git a/docs/guide/readme.md b/docs/guide/readme.md index cc994b971..bb3db3613 100644 --- a/docs/guide/readme.md +++ b/docs/guide/readme.md @@ -10,11 +10,23 @@ Docker and docker-compose are tools for defining and running multi-container Doc ## Download -Copy and paste the contents of the provided example [basic.docker-compose.yml] and save it locally as `docker-compose.yml`. +Copy and paste the contents of the provided example [basic.docker-compose.yml]. ## Configure -Edit the `docker-compose.yml` to match your [identity provider] settings. +### Docker-compose + +Edit the `docker-compose.yml` to match your specific [identity provider]'s settings. For example, `basic.docker-compose.yml`: + +<<< @/docs/docs/examples/docker/basic.docker-compose.yml + +### Policy configuration + +Next, create a policy configuration file which will contain the routes you want to proxy, and their desired access-controls. For example, `policy.example.yaml`: + +<<< @/policy.example.yaml + +### Certificates Place your domain's wild-card TLS certificate next to the compose file. If you don't have one handy, the included [script] generates one from [LetsEncrypt]. diff --git a/env.example b/env.example index 7e2a6e711..0c320ec88 100644 --- a/env.example +++ b/env.example @@ -42,6 +42,11 @@ export IDP_PROVIDER_URL="https://accounts.google.com" # optional for google export IDP_CLIENT_ID="REPLACE-ME.googleusercontent.com" export IDP_CLIENT_SECRET="REPLACEME" +# IF GSUITE and you want to get user groups you will need to set a service account +# see identity provider docs for gooogle for more info : +# GSUITE_JSON_SERVICE_ACCOUNT='{"impersonate_user": "bdd@pomerium.io"}' +# export IDP_SERVICE_ACCOUNT=$(echo $GSUITE_JSON_SERVICE_ACCOUNT | base64) + # OKTA # export IDP_PROVIDER="okta" # export IDP_CLIENT_ID="REPLACEME" @@ -56,8 +61,7 @@ export IDP_CLIENT_SECRET="REPLACEME" # export SCOPE="openid email" # generally, you want the default OIDC scopes -# k/v seperated list of simple routes. If no scheme is set, HTTPS will be used. -# Currently set to httpbin which is a handy utility letting you inspect requests recieved by -# a client application -export ROUTES="httpbin.corp.example.com=httpbin.org" -# export ROUTES="https://weirdlyssl.corp.example.com=http://neverssl.com" #https to http! +# Proxied routes and per-route policies are defined in a policy provided either +# directly as a base64 encoded yaml/json file, or as a path pointing to a +# policy file (`POLICY_FILE`) + export POLICY_FILE="./policy.example.yml" diff --git a/go.mod b/go.mod index 1d749a934..15dec5ba0 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,19 @@ go 1.12 require ( github.com/golang/mock v1.2.0 github.com/golang/protobuf v1.3.0 - github.com/pomerium/envconfig v1.3.1-0.20190112072701-14cbcf832d31 + github.com/google/pprof v0.0.0-20190228041337-2ef8d84b2e3c // indirect + github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6 // indirect + github.com/pomerium/envconfig v1.4.0 github.com/pomerium/go-oidc v2.0.0+incompatible github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect github.com/rs/zerolog v1.12.0 + github.com/stretchr/testify v1.3.0 // indirect + golang.org/x/arch v0.0.0-20190226203302-36aee92af9e8 // indirect golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25 golang.org/x/net v0.0.0-20190228165749-92fc7df08ae7 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 google.golang.org/api v0.1.0 google.golang.org/grpc v1.19.0 gopkg.in/square/go-jose.v2 v2.3.0 + gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index cda5f4e29..414616a68 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,14 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -14,14 +18,22 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.0 h1:kbxbvI4Un1LUWKxufD+BiE6AEExYYgkQLQmLFqA1LFk= github.com/golang/protobuf v1.3.0/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/pprof v0.0.0-20190228041337-2ef8d84b2e3c h1:hqIMb/MbwYamune8FA5YtFAVzfTE8OXRtg9Nf0rzmqo= +github.com/google/pprof v0.0.0-20190228041337-2ef8d84b2e3c/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6 h1:UDMh68UUwekSh5iP2OMhRRZJiiBccgV7axzUG8vi56c= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pomerium/envconfig v1.3.0 h1:/qJ+JOrWKkd/MgSrBDQ6xYJ7sxzqxiIAB/3qgHwdrHY= github.com/pomerium/envconfig v1.3.0/go.mod h1:1Kz8Ca8PhJDtLYqgvbDZGn6GsJCvrT52SxQ3sPNJkDc= github.com/pomerium/envconfig v1.3.1-0.20190112072701-14cbcf832d31 h1:bNqUesLWa+RUxQvSaV3//dEFviXdCSvMF9GKDOopFLU= github.com/pomerium/envconfig v1.3.1-0.20190112072701-14cbcf832d31/go.mod h1:1Kz8Ca8PhJDtLYqgvbDZGn6GsJCvrT52SxQ3sPNJkDc= +github.com/pomerium/envconfig v1.4.0 h1:o+WY/E/9M4fh0nDX7oJodU7N9p1hcHPsTnNLYjlbQA8= +github.com/pomerium/envconfig v1.4.0/go.mod h1:1Kz8Ca8PhJDtLYqgvbDZGn6GsJCvrT52SxQ3sPNJkDc= github.com/pomerium/go-oidc v2.0.0+incompatible h1:gVvG/ExWsHQqatV+uceROnGmbVYF44mDNx5nayBhC0o= github.com/pomerium/go-oidc v2.0.0+incompatible/go.mod h1:DRsGVw6MOgxbfq4Y57jKOE8lbEfayxeiY0A8/4vxjBM= github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= @@ -32,7 +44,12 @@ github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7q github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/rs/zerolog v1.12.0 h1:aqZ1XRadoS8IBknR5IDFvGzbHly1X9ApIqOroooQF/c= github.com/rs/zerolog v1.12.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +golang.org/x/arch v0.0.0-20190226203302-36aee92af9e8 h1:G3kY3WDPiChidkYzLqbniw7jg23paUtzceZorG6YAJw= +golang.org/x/arch v0.0.0-20190226203302-36aee92af9e8/go.mod h1:cYlCBUl1MsqxdiKgmc4uh7TxZfWSFLOGSRR090WDxt8= golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25 h1:jsG6UpNLt9iAsb0S2AGW28DveNzzgmbXR+ENoPjUeIU= golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -50,6 +67,7 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFM golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -64,6 +82,7 @@ google.golang.org/api v0.1.0 h1:K6z2u68e86TPdSdefXdzvXgR1zEMa+459vBSfWYAZkI= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -74,9 +93,12 @@ google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9M google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/square/go-jose.v2 v2.3.0 h1:nLzhkFyl5bkblqYBoiWJUt5JkWOzmiaBtCxdJAqJd3U= gopkg.in/square/go-jose.v2 v2.3.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/cryptutil/hash_test.go b/internal/cryptutil/hash_test.go index 74023ee79..8f5694d72 100644 --- a/internal/cryptutil/hash_test.go +++ b/internal/cryptutil/hash_test.go @@ -11,6 +11,7 @@ import ( ) func TestPasswordHashing(t *testing.T) { + t.Parallel() bcryptTests := []struct { plaintext []byte hash []byte diff --git a/internal/cryptutil/marshal_test.go b/internal/cryptutil/marshal_test.go index f4f1b2046..9a9abc2e0 100644 --- a/internal/cryptutil/marshal_test.go +++ b/internal/cryptutil/marshal_test.go @@ -25,27 +25,6 @@ nFUSTwqQFo5gbfIlP+gvEYba+Rxj2hhqjfzqxIleRK40IRyEi3fJM/8Qhg== -----END PUBLIC KEY----- ` -// A keypair for NIST P-384 / secp384r1 -// Generated using: -// openssl ecparam -genkey -name secp384r1 -outform PEM -var pemECPrivateKeyP384 = `-----BEGIN EC PARAMETERS----- -BgUrgQQAIg== ------END EC PARAMETERS----- ------BEGIN EC PRIVATE KEY----- -MIGkAgEBBDAhA0YPVL1kimIy+FAqzUAtmR3It2Yjv2I++YpcC4oX7wGuEWcWKBYE -oOjj7wG/memgBwYFK4EEACKhZANiAAQub8xaaCTTW5rCHJCqUddIXpvq/TxdwViH -+tPEQQlJAJciXStM/aNLYA7Q1K1zMjYyzKSWz5kAh/+x4rXQ9Hlm3VAwCQDVVSjP -bfiNOXKOWfmyrGyQ7fQfs+ro1lmjLjs= ------END EC PRIVATE KEY----- -` - -var pemECPublicKeyP384 = `-----BEGIN PUBLIC KEY----- -MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAELm/MWmgk01uawhyQqlHXSF6b6v08XcFY -h/rTxEEJSQCXIl0rTP2jS2AO0NStczI2Msykls+ZAIf/seK10PR5Zt1QMAkA1VUo -z234jTlyjln5sqxskO30H7Pq6NZZoy47 ------END PUBLIC KEY----- -` - var garbagePEM = `-----BEGIN GARBAGE----- TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQ= -----END GARBAGE----- diff --git a/internal/cryptutil/sign_test.go b/internal/cryptutil/sign_test.go index e9f380694..b93848db6 100644 --- a/internal/cryptutil/sign_test.go +++ b/internal/cryptutil/sign_test.go @@ -22,7 +22,7 @@ func TestES256Signer(t *testing.T) { } func TestNewES256Signer(t *testing.T) { - + t.Parallel() tests := []struct { name string privKey []byte diff --git a/internal/https/https.go b/internal/https/https.go index 76f096265..263b83e81 100644 --- a/internal/https/https.go +++ b/internal/https/https.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "encoding/base64" "fmt" + stdlog "log" "net" "net/http" "os" @@ -12,6 +13,7 @@ import ( "time" "github.com/pomerium/pomerium/internal/fileutil" + "github.com/pomerium/pomerium/internal/log" "google.golang.org/grpc" ) @@ -90,15 +92,18 @@ func ListenAndServeTLS(opt *Options, httpHandler http.Handler, grpcHandler *grpc } else { h = grpcHandlerFunc(grpcHandler, httpHandler) } + sublogger := log.With().Str("addr", opt.Addr).Logger() + // Set up the main server. server := &http.Server{ - ReadHeaderTimeout: 5 * time.Second, - ReadTimeout: 10 * time.Second, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, // WriteTimeout is set to 0 for streaming replies WriteTimeout: 0, - IdleTimeout: 60 * time.Second, + IdleTimeout: 5 * time.Minute, TLSConfig: config, Handler: h, + ErrorLog: stdlog.New(&log.StdLogWrapper{Logger: &sublogger}, "", 0), } return server.Serve(ln) diff --git a/internal/identity/google.go b/internal/identity/google.go index c3bb3f8e3..44ed1a45b 100644 --- a/internal/identity/google.go +++ b/internal/identity/google.go @@ -44,8 +44,10 @@ func NewGoogleProvider(p *Provider) (*GoogleProvider, error) { if err != nil { return nil, err } + // Google rejects the offline scope favoring "access_type=offline" + // as part of the authorization request instead. if len(p.Scopes) == 0 { - p.Scopes = []string{oidc.ScopeOpenID, "profile", "email", "offline_access"} + p.Scopes = []string{oidc.ScopeOpenID, "profile", "email"} } p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ @@ -86,7 +88,7 @@ func NewGoogleProvider(p *Provider) (*GoogleProvider, error) { return nil, fmt.Errorf("identity/google: failed creating admin service %v", err) } } else { - log.Warn().Msg("identity/google: no service account found, cannot retrieve user groups") + log.Warn().Msg("identity/google: no service account, cannot retrieve groups") } gp.RevokeURL, err = url.Parse(claims.RevokeURL) @@ -114,6 +116,11 @@ func (p *GoogleProvider) Revoke(accessToken string) error { // the required scopes explicitly. // Google requires an additional access scope for offline access which is a requirement for any // application that needs to access a Google API when the user is not present. +// Support for this scope differs between OpenID Connect providers. For instance +// Google rejects it, favoring appending "access_type=offline" as part of the +// authorization request instead. +// +// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess // https://developers.google.com/identity/protocols/OAuth2WebServer#offline func (p *GoogleProvider) GetSignInURL(state string) string { return p.oauth.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce) @@ -167,7 +174,7 @@ func (p *GoogleProvider) Authenticate(code string) (*sessions.SessionState, erro }, nil } -// Refresh renews a user's session using an oid refresh token without reprompting the user. +// Refresh renews a user's session using an oid refresh token withoutreprompting the user. // Group membership is also refreshed. // https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens func (p *GoogleProvider) Refresh(ctx context.Context, s *sessions.SessionState) (*sessions.SessionState, error) { @@ -211,7 +218,6 @@ func (p *GoogleProvider) UserGroups(ctx context.Context, user string) ([]string, return nil, fmt.Errorf("identity/google: group api request failed %v", err) } for _, group := range resp.Groups { - log.Info().Str("group.Name", group.Name).Msg("sanity check3") groups = append(groups, group.Name) } } diff --git a/internal/identity/providers.go b/internal/identity/providers.go index ad4d33606..94d82215a 100644 --- a/internal/identity/providers.go +++ b/internal/identity/providers.go @@ -1,5 +1,3 @@ -//go:generate protoc -I ../../proto/authenticate --go_out=plugins=grpc:../../proto/authenticate ../../proto/authenticate/authenticate.proto - // Package identity provides support for making OpenID Connect and OAuth2 authorized and // authenticated HTTP requests with third party identity providers. package identity // import "github.com/pomerium/pomerium/internal/identity" diff --git a/internal/log/log.go b/internal/log/log.go index 0da0845f5..a8ce53687 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -110,3 +110,19 @@ func Ctx(ctx context.Context) *zerolog.Logger { func FromRequest(r *http.Request) *zerolog.Logger { return Ctx(r.Context()) } + +// StdLogWrapper can be used to wrap logs originating from the from std +// library's ErrorFunction argument in http.Serve and httputil.ReverseProxy. +type StdLogWrapper struct { + *zerolog.Logger +} + +func (l *StdLogWrapper) Write(p []byte) (n int, err error) { + n = len(p) + if n > 0 && p[n-1] == '\n' { + // Trim CR added by stdlog. + p = p[0 : n-1] + } + l.Error().Msg(string(p)) + return len(p), nil +} diff --git a/internal/middleware/chain_test.go b/internal/middleware/chain_test.go index fbd3438d1..876030cbc 100644 --- a/internal/middleware/chain_test.go +++ b/internal/middleware/chain_test.go @@ -70,7 +70,7 @@ func TestThenFuncTreatsNilAsDefaultServeMux(t *testing.T) { func TestThenFuncConstructsHandlerFunc(t *testing.T) { fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) }) chained := NewChain().ThenFunc(fn) rec := httptest.NewRecorder() diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 000000000..2b66dbf59 --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,83 @@ +package policy // import "github.com/pomerium/pomerium/internal/policy" + +import ( + "fmt" + "io/ioutil" + "net/url" + "strings" + "time" + + "github.com/pomerium/pomerium/internal/log" + + "github.com/pomerium/pomerium/internal/fileutil" + yaml "gopkg.in/yaml.v2" +) + +// Policy contains authorization policy information. +// todo(bdd) : add upstream timeout and configuration settings +type Policy struct { + // proxy related + From string `yaml:"from"` + To string `yaml:"to"` + // upstream transport settings + UpstreamTimeout time.Duration `yaml:"timeout"` + // Identity related policy + AllowedEmails []string `yaml:"allowed_users"` + AllowedGroups []string `yaml:"allowed_groups"` + AllowedDomains []string `yaml:"allowed_domains"` + + Source *url.URL + Destination *url.URL +} + +func (p *Policy) validate() (err error) { + p.Source, err = urlParse(p.From) + if err != nil { + return err + } + p.Destination, err = urlParse(p.To) + if err != nil { + return err + } + return nil +} + +// FromConfig parses configuration file as bytes and returns authorization +// policies. Supports yaml, json. +func FromConfig(confBytes []byte) ([]Policy, error) { + var f []Policy + if err := yaml.Unmarshal(confBytes, &f); err != nil { + return nil, err + } + // build source and destination urls + for i := range f { + if err := (&f[i]).validate(); err != nil { + return nil, err + } + } + log.Info().Msgf("from config %+v", f) + return f, nil +} + +// FromConfigFile parses configuration file from a path and returns +// authorization policies. Supports yaml, json. +func FromConfigFile(f string) ([]Policy, error) { + exists, err := fileutil.IsReadableFile(f) + if err != nil || !exists { + return nil, fmt.Errorf("policy file %v: %v exists? %v", f, err, exists) + } + confBytes, err := ioutil.ReadFile(f) + if err != nil { + return nil, err + } + return FromConfig(confBytes) +} + +// urlParse wraps url.Parse to add a scheme if none-exists. +// https://github.com/golang/go/issues/12585 +func urlParse(uri string) (*url.URL, error) { + if !strings.Contains(uri, "://") { + uri = fmt.Sprintf("https://%s", uri) + } + return url.ParseRequestURI(uri) +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 000000000..70338df95 --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,92 @@ +package policy + +import ( + "net/url" + "reflect" + "testing" +) + +func TestFromConfig(t *testing.T) { + t.Parallel() + source, _ := urlParse("pomerium.io") + dest, _ := urlParse("httpbin.org") + + tests := []struct { + name string + yamlBytes []byte + want []Policy + wantErr bool + }{ + {"simple json", []byte(`[{"from": "pomerium.io","to":"httpbin.org"}]`), []Policy{{From: "pomerium.io", To: "httpbin.org", Source: source, Destination: dest}}, false}, + {"bad from", []byte(`[{"from": "%","to":"httpbin.org"}]`), nil, true}, + {"bad to", []byte(`[{"from": "pomerium.io","to":"%"}]`), nil, true}, + {"simple error", []byte(`{}`), nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FromConfig(tt.yamlBytes) + if (err != nil) != tt.wantErr { + t.Errorf("FromConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FromConfig() = \n%v, want \n%v", got, tt.want) + } + }) + } +} + +func Test_urlParse(t *testing.T) { + t.Parallel() + tests := []struct { + name string + uri string + want *url.URL + wantErr bool + }{ + {"good url without schema", "accounts.google.com", &url.URL{Scheme: "https", Host: "accounts.google.com"}, false}, + {"good url with schema", "https://accounts.google.com", &url.URL{Scheme: "https", Host: "accounts.google.com"}, false}, + {"bad url, malformed", "https://accounts.google.^", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := urlParse(tt.uri) + if (err != nil) != tt.wantErr { + t.Errorf("urlParse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("urlParse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFromConfigFile(t *testing.T) { + t.Parallel() + source, _ := urlParse("pomerium.io") + dest, _ := urlParse("httpbin.org") + + tests := []struct { + name string + f string + want []Policy + wantErr bool + }{ + {"simple json", "./testdata/basic.json", []Policy{{From: "pomerium.io", To: "httpbin.org", Source: source, Destination: dest}}, false}, + {"simple yaml", "./testdata/basic.yaml", []Policy{{From: "pomerium.io", To: "httpbin.org", Source: source, Destination: dest}}, false}, + {"failed dir", "./testdata/", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FromConfigFile(tt.f) + if (err != nil) != tt.wantErr { + t.Errorf("FromConfigFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FromConfigFile() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/policy/testdata/basic.json b/internal/policy/testdata/basic.json new file mode 100644 index 000000000..4871c7eb3 --- /dev/null +++ b/internal/policy/testdata/basic.json @@ -0,0 +1,6 @@ +[ + { + "from": "pomerium.io", + "to": "httpbin.org" + } +] \ No newline at end of file diff --git a/internal/policy/testdata/basic.yaml b/internal/policy/testdata/basic.yaml new file mode 100644 index 000000000..0c03371dd --- /dev/null +++ b/internal/policy/testdata/basic.yaml @@ -0,0 +1,2 @@ +- from: pomerium.io + to: httpbin.org diff --git a/internal/sessions/cookie_store_test.go b/internal/sessions/cookie_store_test.go index 74754aae5..59dec78ef 100644 --- a/internal/sessions/cookie_store_test.go +++ b/internal/sessions/cookie_store_test.go @@ -106,17 +106,6 @@ func TestCookieStore_makeCookie(t *testing.T) { t.Fatal(err) } - type fields struct { - Name string - CSRFCookieName string - CookieCipher cryptutil.Cipher - CookieExpire time.Duration - CookieRefresh time.Duration - CookieSecure bool - CookieHTTPOnly bool - CookieDomain string - } - now := time.Now() tests := []struct { name string diff --git a/policy.example.yaml b/policy.example.yaml new file mode 100644 index 000000000..f91b4b032 --- /dev/null +++ b/policy.example.yaml @@ -0,0 +1,19 @@ +- from: httpbin.corp.beyondperimeter.com + to: http://httpbin + allowed_domains: + - pomerium.io +- from: external-httpbin.corp.beyondperimeter.com + to: httpbin.org + allowed_domains: + - gmail.com +- from: weirdlyssl.corp.beyondperimeter.com + to: http://neverssl.com + allowed_users: + - bdd@pomerium.io + allowed_groups: + - admins + - developers +- from: hello.corp.beyondperimeter.com + to: http://hello:8080 + allowed_groups: + - admins diff --git a/proto/authenticate/mock_authenticate/authenticate_mock_test.go b/proto/authenticate/mock_authenticate/authenticate_mock_test.go deleted file mode 100644 index 1003a88a1..000000000 --- a/proto/authenticate/mock_authenticate/authenticate_mock_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package mock_authenticate_test - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/golang/mock/gomock" - "github.com/golang/protobuf/proto" - "github.com/golang/protobuf/ptypes" - pb "github.com/pomerium/pomerium/proto/authenticate" - - mock "github.com/pomerium/pomerium/proto/authenticate/mock_authenticate" -) - -var fixedDate = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) - -// rpcMsg implements the gomock.Matcher interface -type rpcMsg struct { - msg proto.Message -} - -func (r *rpcMsg) Matches(msg interface{}) bool { - m, ok := msg.(proto.Message) - if !ok { - return false - } - return proto.Equal(m, r.msg) -} - -func (r *rpcMsg) String() string { - return fmt.Sprintf("is %s", r.msg) -} -func TestValidate(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - mockAuthenticateClient := mock.NewMockAuthenticatorClient(ctrl) - req := &pb.ValidateRequest{IdToken: "unit_test"} - mockAuthenticateClient.EXPECT().Validate( - gomock.Any(), - &rpcMsg{msg: req}, - ).Return(&pb.ValidateReply{IsValid: false}, nil) - testValidate(t, mockAuthenticateClient) -} - -func testValidate(t *testing.T, client pb.AuthenticatorClient) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - r, err := client.Validate(ctx, &pb.ValidateRequest{IdToken: "unit_test"}) - if err != nil || r.IsValid != false { - t.Errorf("mocking failed") - } - t.Log("Reply : ", r.IsValid) -} - -func TestAuthenticate(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - mockAuthenticateClient := mock.NewMockAuthenticatorClient(ctrl) - mockExpire, err := ptypes.TimestampProto(fixedDate) - if err != nil { - t.Fatalf("%v failed converting timestamp", err) - } - req := &pb.AuthenticateRequest{Code: "unit_test"} - mockAuthenticateClient.EXPECT().Authenticate( - gomock.Any(), - &rpcMsg{msg: req}, - ).Return(&pb.Session{ - AccessToken: "mocked access token", - RefreshToken: "mocked refresh token", - IdToken: "mocked id token", - User: "user1", - Email: "test@email.com", - LifetimeDeadline: mockExpire, - }, nil) - testAuthenticate(t, mockAuthenticateClient) -} - -func testAuthenticate(t *testing.T, client pb.AuthenticatorClient) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - r, err := client.Authenticate(ctx, &pb.AuthenticateRequest{Code: "unit_test"}) - if err != nil { - t.Errorf("mocking failed %v", err) - } - if r.AccessToken != "mocked access token" { - t.Errorf("authenticate: invalid access token") - } - if r.RefreshToken != "mocked refresh token" { - t.Errorf("authenticate: invalid refresh token") - } - if r.IdToken != "mocked id token" { - t.Errorf("authenticate: invalid id token") - } - if r.User != "user1" { - t.Errorf("authenticate: invalid user") - } - if r.Email != "test@email.com" { - t.Errorf("authenticate: invalid email") - } -} - -func TestRefresh(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - mockRefreshClient := mock.NewMockAuthenticatorClient(ctrl) - mockExpire, err := ptypes.TimestampProto(fixedDate) - if err != nil { - t.Fatalf("%v failed converting timestamp", err) - } - req := &pb.Session{RefreshToken: "unit_test"} - mockRefreshClient.EXPECT().Refresh( - gomock.Any(), - &rpcMsg{msg: req}, - ).Return(&pb.Session{ - AccessToken: "mocked access token", - LifetimeDeadline: mockExpire, - }, nil) - testRefresh(t, mockRefreshClient) -} - -func testRefresh(t *testing.T, client pb.AuthenticatorClient) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - r, err := client.Refresh(ctx, &pb.Session{RefreshToken: "unit_test"}) - if err != nil { - t.Errorf("mocking failed %v", err) - } - if r.AccessToken != "mocked access token" { - t.Errorf("Refresh: invalid access token") - } - respExpire, err := ptypes.Timestamp(r.LifetimeDeadline) - if err != nil { - t.Fatalf("%v failed converting timestamp", err) - } - - if respExpire != fixedDate { - t.Errorf("Refresh: bad expiry got:%v want:%v", respExpire, fixedDate) - } - -} diff --git a/proto/authorize/authorize.pb.go b/proto/authorize/authorize.pb.go new file mode 100644 index 000000000..cdc266b12 --- /dev/null +++ b/proto/authorize/authorize.pb.go @@ -0,0 +1,221 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: authorize.proto + +package authorize + +import proto "github.com/golang/protobuf/proto" +import fmt "fmt" +import math "math" + +import ( + context "golang.org/x/net/context" + grpc "google.golang.org/grpc" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package + +type AuthorizeRequest struct { + // request context + Route string `protobuf:"bytes,1,opt,name=route,proto3" json:"route,omitempty"` + // user context + User string `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + Groups []string `protobuf:"bytes,4,rep,name=groups,proto3" json:"groups,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *AuthorizeRequest) Reset() { *m = AuthorizeRequest{} } +func (m *AuthorizeRequest) String() string { return proto.CompactTextString(m) } +func (*AuthorizeRequest) ProtoMessage() {} +func (*AuthorizeRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_authorize_dad4e29706fc340b, []int{0} +} +func (m *AuthorizeRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_AuthorizeRequest.Unmarshal(m, b) +} +func (m *AuthorizeRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_AuthorizeRequest.Marshal(b, m, deterministic) +} +func (dst *AuthorizeRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_AuthorizeRequest.Merge(dst, src) +} +func (m *AuthorizeRequest) XXX_Size() int { + return xxx_messageInfo_AuthorizeRequest.Size(m) +} +func (m *AuthorizeRequest) XXX_DiscardUnknown() { + xxx_messageInfo_AuthorizeRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_AuthorizeRequest proto.InternalMessageInfo + +func (m *AuthorizeRequest) GetRoute() string { + if m != nil { + return m.Route + } + return "" +} + +func (m *AuthorizeRequest) GetUser() string { + if m != nil { + return m.User + } + return "" +} + +func (m *AuthorizeRequest) GetEmail() string { + if m != nil { + return m.Email + } + return "" +} + +func (m *AuthorizeRequest) GetGroups() []string { + if m != nil { + return m.Groups + } + return nil +} + +type AuthorizeReply struct { + IsValid bool `protobuf:"varint,1,opt,name=is_valid,json=isValid,proto3" json:"is_valid,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *AuthorizeReply) Reset() { *m = AuthorizeReply{} } +func (m *AuthorizeReply) String() string { return proto.CompactTextString(m) } +func (*AuthorizeReply) ProtoMessage() {} +func (*AuthorizeReply) Descriptor() ([]byte, []int) { + return fileDescriptor_authorize_dad4e29706fc340b, []int{1} +} +func (m *AuthorizeReply) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_AuthorizeReply.Unmarshal(m, b) +} +func (m *AuthorizeReply) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_AuthorizeReply.Marshal(b, m, deterministic) +} +func (dst *AuthorizeReply) XXX_Merge(src proto.Message) { + xxx_messageInfo_AuthorizeReply.Merge(dst, src) +} +func (m *AuthorizeReply) XXX_Size() int { + return xxx_messageInfo_AuthorizeReply.Size(m) +} +func (m *AuthorizeReply) XXX_DiscardUnknown() { + xxx_messageInfo_AuthorizeReply.DiscardUnknown(m) +} + +var xxx_messageInfo_AuthorizeReply proto.InternalMessageInfo + +func (m *AuthorizeReply) GetIsValid() bool { + if m != nil { + return m.IsValid + } + return false +} + +func init() { + proto.RegisterType((*AuthorizeRequest)(nil), "authorize.AuthorizeRequest") + proto.RegisterType((*AuthorizeReply)(nil), "authorize.AuthorizeReply") +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConn + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion4 + +// AuthorizerClient is the client API for Authorizer service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type AuthorizerClient interface { + Authorize(ctx context.Context, in *AuthorizeRequest, opts ...grpc.CallOption) (*AuthorizeReply, error) +} + +type authorizerClient struct { + cc *grpc.ClientConn +} + +func NewAuthorizerClient(cc *grpc.ClientConn) AuthorizerClient { + return &authorizerClient{cc} +} + +func (c *authorizerClient) Authorize(ctx context.Context, in *AuthorizeRequest, opts ...grpc.CallOption) (*AuthorizeReply, error) { + out := new(AuthorizeReply) + err := c.cc.Invoke(ctx, "/authorize.Authorizer/Authorize", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AuthorizerServer is the server API for Authorizer service. +type AuthorizerServer interface { + Authorize(context.Context, *AuthorizeRequest) (*AuthorizeReply, error) +} + +func RegisterAuthorizerServer(s *grpc.Server, srv AuthorizerServer) { + s.RegisterService(&_Authorizer_serviceDesc, srv) +} + +func _Authorizer_Authorize_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AuthorizeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServer).Authorize(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/authorize.Authorizer/Authorize", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServer).Authorize(ctx, req.(*AuthorizeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _Authorizer_serviceDesc = grpc.ServiceDesc{ + ServiceName: "authorize.Authorizer", + HandlerType: (*AuthorizerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Authorize", + Handler: _Authorizer_Authorize_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "authorize.proto", +} + +func init() { proto.RegisterFile("authorize.proto", fileDescriptor_authorize_dad4e29706fc340b) } + +var fileDescriptor_authorize_dad4e29706fc340b = []byte{ + // 187 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x4f, 0x2c, 0x2d, 0xc9, + 0xc8, 0x2f, 0xca, 0xac, 0x4a, 0xd5, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0xe2, 0x84, 0x0b, 0x28, + 0x65, 0x71, 0x09, 0x38, 0xc2, 0x38, 0x41, 0xa9, 0x85, 0xa5, 0xa9, 0xc5, 0x25, 0x42, 0x22, 0x5c, + 0xac, 0x45, 0xf9, 0xa5, 0x25, 0xa9, 0x12, 0x8c, 0x0a, 0x8c, 0x1a, 0x9c, 0x41, 0x10, 0x8e, 0x90, + 0x10, 0x17, 0x4b, 0x69, 0x71, 0x6a, 0x91, 0x04, 0x13, 0x58, 0x10, 0xcc, 0x06, 0xa9, 0x4c, 0xcd, + 0x4d, 0xcc, 0xcc, 0x91, 0x60, 0x86, 0xa8, 0x04, 0x73, 0x84, 0xc4, 0xb8, 0xd8, 0xd2, 0x8b, 0xf2, + 0x4b, 0x0b, 0x8a, 0x25, 0x58, 0x14, 0x98, 0x35, 0x38, 0x83, 0xa0, 0x3c, 0x25, 0x6d, 0x2e, 0x3e, + 0x24, 0xbb, 0x0a, 0x72, 0x2a, 0x85, 0x24, 0xb9, 0x38, 0x32, 0x8b, 0xe3, 0xcb, 0x12, 0x73, 0x32, + 0x53, 0xc0, 0x96, 0x71, 0x04, 0xb1, 0x67, 0x16, 0x87, 0x81, 0xb8, 0x46, 0xc1, 0x5c, 0x5c, 0x70, + 0xc5, 0x45, 0x42, 0xae, 0x5c, 0x9c, 0x70, 0x9e, 0x90, 0xb4, 0x1e, 0xc2, 0x43, 0xe8, 0x8e, 0x97, + 0x92, 0xc4, 0x2e, 0x59, 0x90, 0x53, 0xa9, 0xc4, 0x90, 0xc4, 0x06, 0xf6, 0xbf, 0x31, 0x20, 0x00, + 0x00, 0xff, 0xff, 0x28, 0xac, 0x76, 0x2d, 0x12, 0x01, 0x00, 0x00, +} diff --git a/proto/authorize/authorize.proto b/proto/authorize/authorize.proto new file mode 100644 index 000000000..a394903ce --- /dev/null +++ b/proto/authorize/authorize.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package authorize; + +service Authorizer { + rpc Authorize(AuthorizeRequest) returns (AuthorizeReply) {} +} + +message AuthorizeRequest { + // request context + string route = 1; + // user context + string user = 2; + string email = 3; + repeated string groups = 4; +} + +message AuthorizeReply { bool is_valid = 1; } diff --git a/proxy/authenticator/authenticator.go b/proxy/authenticator/authenticator.go deleted file mode 100644 index 75f8f6396..000000000 --- a/proxy/authenticator/authenticator.go +++ /dev/null @@ -1,45 +0,0 @@ -package authenticator // import "github.com/pomerium/pomerium/proxy/authenticator" - -import ( - "context" - - "github.com/pomerium/pomerium/internal/sessions" -) - -// Authenticator provides the authenticate service interface -type Authenticator interface { - // Redeem takes a code and returns a validated session or an error - Redeem(context.Context, string) (*sessions.SessionState, error) - // Refresh attempts to refresh a valid session with a refresh token. Returns a refreshed session. - Refresh(context.Context, *sessions.SessionState) (*sessions.SessionState, error) - // Validate evaluates a given oidc id_token for validity. Returns validity and any error. - Validate(context.Context, string) (bool, error) - // Close closes the authenticator connection if any. - Close() error -} - -// Options contains options for connecting to an authenticate service . -type Options struct { - // Addr is the location of the authenticate service. Used if InternalAddr is not set. - Addr string - Port int - // InternalAddr is the internal (behind the ingress) address to use when making an - // authentication connection. If empty, Addr is used. - InternalAddr string - // OverrideCertificateName overrides the server name used to verify the hostname on the - // returned certificates from the server. gRPC internals also use it to override the virtual - // hosting name if it is set. - OverrideCertificateName string - // Shared secret is used to authenticate a authenticate-client with a authenticate-server. - SharedSecret string - // CA specifies the base64 encoded TLS certificate authority to use. - CA string - // CAFile specifies the TLS certificate authority file to use. - CAFile string -} - -// New returns a new authenticate service client. Takes a client implementation name as an argument. -// Currently only gRPC is supported and is always returned. -func New(name string, opts *Options) (a Authenticator, err error) { - return NewGRPC(opts) -} diff --git a/proxy/authenticator/grpc.go b/proxy/clients/authenticate_client.go similarity index 59% rename from proxy/authenticator/grpc.go rename to proxy/clients/authenticate_client.go index 6bb7924b6..6e6e1cc1b 100644 --- a/proxy/authenticator/grpc.go +++ b/proxy/clients/authenticate_client.go @@ -1,90 +1,37 @@ -package authenticator // import "github.com/pomerium/pomerium/proxy/authenticator" +package clients // import "github.com/pomerium/pomerium/proxy/clients" + import ( "context" - "crypto/tls" - "crypto/x509" - "encoding/base64" "errors" - "fmt" - "io/ioutil" - "strings" "time" "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/sessions" pb "github.com/pomerium/pomerium/proto/authenticate" ) -// NewGRPC returns a new authenticate service client. -func NewGRPC(opts *Options) (p *AuthenticateGRPC, err error) { - // gRPC uses a pre-shared secret middleware to establish authentication b/w server and client - if opts.SharedSecret == "" { - return nil, errors.New("proxy/authenticator: grpc client requires shared secret") - } - grpcAuth := middleware.NewSharedSecretCred(opts.SharedSecret) +// Authenticator provides the authenticate service interface +type Authenticator interface { + // Redeem takes a code and returns a validated session or an error + Redeem(context.Context, string) (*sessions.SessionState, error) + // Refresh attempts to refresh a valid session with a refresh token. Returns a refreshed session. + Refresh(context.Context, *sessions.SessionState) (*sessions.SessionState, error) + // Validate evaluates a given oidc id_token for validity. Returns validity and any error. + Validate(context.Context, string) (bool, error) + // Close closes the authenticator connection if any. + Close() error +} - var connAddr string - if opts.InternalAddr != "" { - connAddr = opts.InternalAddr - } else { - connAddr = opts.Addr - } - if connAddr == "" { - return nil, errors.New("proxy/authenticator: connection address required") - } - // no colon exists in the connection string, assume one must be added manually - if !strings.Contains(connAddr, ":") { - connAddr = fmt.Sprintf("%s:%d", connAddr, opts.Port) - } +// NewAuthenticateClient returns a new authenticate service client. +func NewAuthenticateClient(name string, opts *Options) (a Authenticator, err error) { + // Only gRPC is supported and is always returned so name is ignored + return NewGRPCAuthenticateClient(opts) +} - var cp *x509.CertPool - if opts.CA != "" || opts.CAFile != "" { - cp = x509.NewCertPool() - var ca []byte - var err error - if opts.CA != "" { - ca, err = base64.StdEncoding.DecodeString(opts.CA) - if err != nil { - return nil, fmt.Errorf("failed to decode certificate authority: %v", err) - } - } else { - ca, err = ioutil.ReadFile(opts.CAFile) - if err != nil { - return nil, fmt.Errorf("certificate authority file %v not readable: %v", opts.CAFile, err) - } - } - if ok := cp.AppendCertsFromPEM(ca); !ok { - return nil, fmt.Errorf("failed to append CA cert to certPool") - } - } else { - newCp, err := x509.SystemCertPool() - if err != nil { - return nil, err - } - cp = newCp - } - - log.Info(). - Str("OverrideCertificateName", opts.OverrideCertificateName). - Str("addr", connAddr).Msgf("proxy/authenticator: grpc connection") - cert := credentials.NewTLS(&tls.Config{RootCAs: cp}) - - // override allowed certificate name string, typically used when doing behind ingress connection - if opts.OverrideCertificateName != "" { - err = cert.OverrideServerName(opts.OverrideCertificateName) - if err != nil { - return nil, err - } - } - conn, err := grpc.Dial( - connAddr, - grpc.WithTransportCredentials(cert), - grpc.WithPerRPCCredentials(grpcAuth), - ) +// NewGRPCAuthenticateClient returns a new authenticate service client. +func NewGRPCAuthenticateClient(opts *Options) (p *AuthenticateGRPC, err error) { + conn, err := NewGRPCClientConn(opts) if err != nil { return nil, err } diff --git a/proxy/authenticator/grpc_test.go b/proxy/clients/authenticate_client_test.go similarity index 58% rename from proxy/authenticator/grpc_test.go rename to proxy/clients/authenticate_client_test.go index bf0b96dda..5657c2cdb 100644 --- a/proxy/authenticator/grpc_test.go +++ b/proxy/clients/authenticate_client_test.go @@ -1,4 +1,4 @@ -package authenticator +package clients // import "github.com/pomerium/pomerium/proxy/clients" import ( "context" @@ -16,6 +16,27 @@ import ( mock "github.com/pomerium/pomerium/proto/authenticate/mock_authenticate" ) +func TestNew(t *testing.T) { + tests := []struct { + name string + serviceName string + opts *Options + wantErr bool + }{ + {"grpc good", "grpc", &Options{Addr: "test", InternalAddr: "intranet.local", SharedSecret: "secret"}, false}, + {"grpc missing shared secret", "grpc", &Options{Addr: "test", InternalAddr: "intranet.local", SharedSecret: ""}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewAuthenticateClient(tt.serviceName, tt.opts) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + var fixedDate = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) // rpcMsg implements the gomock.Matcher interface @@ -194,27 +215,27 @@ func TestNewGRPC(t *testing.T) { wantTarget string }{ {"no shared secret", &Options{}, true, "proxy/authenticator: grpc client requires shared secret", ""}, - {"empty connection", &Options{Addr: "", Port: 443, SharedSecret: "shh"}, true, "proxy/authenticator: connection address required", ""}, - {"both internal and addr empty", &Options{Addr: "", Port: 443, InternalAddr: "", SharedSecret: "shh"}, true, "proxy/authenticator: connection address required", ""}, - {"internal addr with port", &Options{Addr: "", Port: 443, InternalAddr: "intranet.local:8443", SharedSecret: "shh"}, false, "", "intranet.local:8443"}, - {"internal addr without port", &Options{Addr: "", Port: 443, InternalAddr: "intranet.local", SharedSecret: "shh"}, false, "", "intranet.local:443"}, - {"cert override", &Options{Addr: "", Port: 443, InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh"}, false, "", "intranet.local:443"}, - {"custom ca", &Options{Addr: "", Port: 443, InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CA: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURFVENDQWZrQ0ZBWHhneFg5K0hjWlBVVVBEK0laV0NGNUEvVTdNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1FVXgKQ3pBSkJnTlZCQVlUQWtGVk1STXdFUVlEVlFRSURBcFRiMjFsTFZOMFlYUmxNU0V3SHdZRFZRUUtEQmhKYm5SbApjbTVsZENCWGFXUm5hWFJ6SUZCMGVTQk1kR1F3SGhjTk1Ua3dNakk0TVRnMU1EQTNXaGNOTWprd01qSTFNVGcxCk1EQTNXakJGTVFzd0NRWURWUVFHRXdKQlZURVRNQkVHQTFVRUNBd0tVMjl0WlMxVGRHRjBaVEVoTUI4R0ExVUUKQ2d3WVNXNTBaWEp1WlhRZ1YybGtaMmwwY3lCUWRIa2dUSFJrTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQwpBUThBTUlJQkNnS0NBUUVBOVRFMEFiaTdnMHhYeURkVUtEbDViNTBCT05ZVVVSc3F2THQrSWkwdlpjMzRRTHhOClJrT0hrOFZEVUgzcUt1N2UrNGVubUdLVVNUdzRPNFlkQktiSWRJTFpnb3o0YitNL3FVOG5adVpiN2pBVTdOYWkKajMzVDVrbXB3L2d4WHNNUzNzdUpXUE1EUDB3Z1BUZUVRK2J1bUxVWmpLdUVIaWNTL0l5dmtaVlBzRlE4NWlaUwpkNXE2a0ZGUUdjWnFXeFg0dlhDV25Sd3E3cHY3TThJd1RYc1pYSVRuNXB5Z3VTczNKb29GQkg5U3ZNTjRKU25GCmJMK0t6ekduMy9ScXFrTXpMN3FUdkMrNWxVT3UxUmNES21mZXBuVGVaN1IyVnJUQm42NndWMjVHRnBkSDIzN00KOXhJVkJrWEd1U2NvWHVPN1lDcWFrZkt6aXdoRTV4UmRaa3gweXdJREFRQUJNQTBHQ1NxR1NJYjNEUUVCQ3dVQQpBNElCQVFCaHRWUEI0OCs4eFZyVmRxM1BIY3k5QkxtVEtrRFl6N2Q0ODJzTG1HczBuVUdGSTFZUDdmaFJPV3ZxCktCTlpkNEI5MUpwU1NoRGUrMHpoNno4WG5Ha01mYnRSYWx0NHEwZ3lKdk9hUWhqQ3ZCcSswTFk5d2NLbXpFdnMKcTRiNUZ5NXNpRUZSekJLTmZtTGwxTTF2cW1hNmFCVnNYUUhPREdzYS83dE5MalZ2ay9PYm52cFg3UFhLa0E3cQpLMTQvV0tBRFBJWm9mb00xMzB4Q1RTYXVpeXROajlnWkx1WU9leEZhblVwNCt2MHBYWS81OFFSNTk2U0ROVTlKClJaeDhwTzBTaUYvZXkxVUZXbmpzdHBjbTQzTFVQKzFwU1hFeVhZOFJrRTI2QzNvdjNaTFNKc2pMbC90aXVqUlgKZUJPOWorWDdzS0R4amdtajBPbWdpVkpIM0YrUAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="}, false, "", "intranet.local:443"}, - {"bad ca encoding", &Options{Addr: "", Port: 443, InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CA: "^"}, true, "", "intranet.local:443"}, - {"custom ca file", &Options{Addr: "", Port: 443, InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CAFile: "testdata/example.crt"}, false, "", "intranet.local:443"}, - {"bad custom ca file", &Options{Addr: "", Port: 443, InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CAFile: "testdata/example.crt2"}, true, "", "intranet.local:443"}, + {"empty connection", &Options{Addr: "", SharedSecret: "shh"}, true, "proxy/authenticator: connection address required", ""}, + {"both internal and addr empty", &Options{Addr: "", InternalAddr: "", SharedSecret: "shh"}, true, "proxy/authenticator: connection address required", ""}, + {"internal addr with port", &Options{Addr: "", InternalAddr: "intranet.local:8443", SharedSecret: "shh"}, false, "", "intranet.local:8443"}, + {"internal addr without port", &Options{Addr: "", InternalAddr: "intranet.local", SharedSecret: "shh"}, false, "", "intranet.local:443"}, + {"cert override", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh"}, false, "", "intranet.local:443"}, + {"custom ca", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CA: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURFVENDQWZrQ0ZBWHhneFg5K0hjWlBVVVBEK0laV0NGNUEvVTdNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1FVXgKQ3pBSkJnTlZCQVlUQWtGVk1STXdFUVlEVlFRSURBcFRiMjFsTFZOMFlYUmxNU0V3SHdZRFZRUUtEQmhKYm5SbApjbTVsZENCWGFXUm5hWFJ6SUZCMGVTQk1kR1F3SGhjTk1Ua3dNakk0TVRnMU1EQTNXaGNOTWprd01qSTFNVGcxCk1EQTNXakJGTVFzd0NRWURWUVFHRXdKQlZURVRNQkVHQTFVRUNBd0tVMjl0WlMxVGRHRjBaVEVoTUI4R0ExVUUKQ2d3WVNXNTBaWEp1WlhRZ1YybGtaMmwwY3lCUWRIa2dUSFJrTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQwpBUThBTUlJQkNnS0NBUUVBOVRFMEFiaTdnMHhYeURkVUtEbDViNTBCT05ZVVVSc3F2THQrSWkwdlpjMzRRTHhOClJrT0hrOFZEVUgzcUt1N2UrNGVubUdLVVNUdzRPNFlkQktiSWRJTFpnb3o0YitNL3FVOG5adVpiN2pBVTdOYWkKajMzVDVrbXB3L2d4WHNNUzNzdUpXUE1EUDB3Z1BUZUVRK2J1bUxVWmpLdUVIaWNTL0l5dmtaVlBzRlE4NWlaUwpkNXE2a0ZGUUdjWnFXeFg0dlhDV25Sd3E3cHY3TThJd1RYc1pYSVRuNXB5Z3VTczNKb29GQkg5U3ZNTjRKU25GCmJMK0t6ekduMy9ScXFrTXpMN3FUdkMrNWxVT3UxUmNES21mZXBuVGVaN1IyVnJUQm42NndWMjVHRnBkSDIzN00KOXhJVkJrWEd1U2NvWHVPN1lDcWFrZkt6aXdoRTV4UmRaa3gweXdJREFRQUJNQTBHQ1NxR1NJYjNEUUVCQ3dVQQpBNElCQVFCaHRWUEI0OCs4eFZyVmRxM1BIY3k5QkxtVEtrRFl6N2Q0ODJzTG1HczBuVUdGSTFZUDdmaFJPV3ZxCktCTlpkNEI5MUpwU1NoRGUrMHpoNno4WG5Ha01mYnRSYWx0NHEwZ3lKdk9hUWhqQ3ZCcSswTFk5d2NLbXpFdnMKcTRiNUZ5NXNpRUZSekJLTmZtTGwxTTF2cW1hNmFCVnNYUUhPREdzYS83dE5MalZ2ay9PYm52cFg3UFhLa0E3cQpLMTQvV0tBRFBJWm9mb00xMzB4Q1RTYXVpeXROajlnWkx1WU9leEZhblVwNCt2MHBYWS81OFFSNTk2U0ROVTlKClJaeDhwTzBTaUYvZXkxVUZXbmpzdHBjbTQzTFVQKzFwU1hFeVhZOFJrRTI2QzNvdjNaTFNKc2pMbC90aXVqUlgKZUJPOWorWDdzS0R4amdtajBPbWdpVkpIM0YrUAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="}, false, "", "intranet.local:443"}, + {"bad ca encoding", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CA: "^"}, true, "", "intranet.local:443"}, + {"custom ca file", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CAFile: "testdata/example.crt"}, false, "", "intranet.local:443"}, + {"bad custom ca file", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CAFile: "testdata/example.crt2"}, true, "", "intranet.local:443"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewGRPC(tt.opts) + got, err := NewGRPCAuthenticateClient(tt.opts) if (err != nil) != tt.wantErr { - t.Errorf("NewGRPC() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("NewGRPCAuthenticateClient() error = %v, wantErr %v", err, tt.wantErr) if !strings.EqualFold(err.Error(), tt.wantErrStr) { - t.Errorf("NewGRPC() error = %v did not contain wantErr %v", err, tt.wantErrStr) + t.Errorf("NewGRPCAuthenticateClient() error = %v did not contain wantErr %v", err, tt.wantErrStr) } } if got != nil && got.Conn.Target() != tt.wantTarget { - t.Errorf("NewGRPC() target = %v expected %v", got.Conn.Target(), tt.wantTarget) + t.Errorf("NewGRPCAuthenticateClient() target = %v expected %v", got.Conn.Target(), tt.wantTarget) } }) diff --git a/proxy/clients/authorize_client.go b/proxy/clients/authorize_client.go new file mode 100644 index 000000000..3bab43aea --- /dev/null +++ b/proxy/clients/authorize_client.go @@ -0,0 +1,64 @@ +package clients // import "github.com/pomerium/pomerium/proxy/clients" + +import ( + "context" + "errors" + "time" + + pb "github.com/pomerium/pomerium/proto/authorize" + "google.golang.org/grpc" + + "github.com/pomerium/pomerium/internal/sessions" +) + +// Authorizer provides the authorize service interface +type Authorizer interface { + // Authorize takes a code and returns a validated session or an error + Authorize(context.Context, string, *sessions.SessionState) (bool, error) + // Close closes the auth connection if any. + Close() error +} + +// NewAuthorizeClient returns a new authorize service client. +func NewAuthorizeClient(name string, opts *Options) (a Authorizer, err error) { + // Only gRPC is supported and is always returned so name is ignored + return NewGRPCAuthorizeClient(opts) +} + +// NewGRPCAuthorizeClient returns a new authorize service client. +func NewGRPCAuthorizeClient(opts *Options) (p *AuthorizeGRPC, err error) { + conn, err := NewGRPCClientConn(opts) + if err != nil { + return nil, err + } + client := pb.NewAuthorizerClient(conn) + return &AuthorizeGRPC{Conn: conn, client: client}, nil +} + +// AuthorizeGRPC is a gRPC implementation of an authenticator (authenticate client) +type AuthorizeGRPC struct { + Conn *grpc.ClientConn + client pb.AuthorizerClient +} + +// Authorize makes an RPC call to the authorize service to creates a session state +// from an encrypted code provided as a result of an oauth2 callback process. +func (a *AuthorizeGRPC) Authorize(ctx context.Context, route string, s *sessions.SessionState) (bool, error) { + if s == nil { + return false, errors.New("session cannot be nil") + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + response, err := a.client.Authorize(ctx, &pb.AuthorizeRequest{ + Route: route, + User: s.User, + Email: s.Email, + Groups: s.Groups, + }) + return response.GetIsValid(), err +} + +// Close tears down the ClientConn and all underlying connections. +func (a *AuthorizeGRPC) Close() error { + return a.Conn.Close() +} diff --git a/proxy/clients/authorize_client_test.go b/proxy/clients/authorize_client_test.go new file mode 100644 index 000000000..55751b22c --- /dev/null +++ b/proxy/clients/authorize_client_test.go @@ -0,0 +1,47 @@ +package clients + +import ( + "context" + "testing" + + "github.com/pomerium/pomerium/internal/sessions" + pb "github.com/pomerium/pomerium/proto/authorize" + "google.golang.org/grpc" +) + +func TestAuthorizeGRPC_Authorize(t *testing.T) { + type fields struct { + Conn *grpc.ClientConn + client pb.AuthorizerClient + } + type args struct { + ctx context.Context + route string + s *sessions.SessionState + } + tests := []struct { + name string + fields fields + args args + want bool + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &AuthorizeGRPC{ + Conn: tt.fields.Conn, + client: tt.fields.client, + } + got, err := a.Authorize(tt.args.ctx, tt.args.route, tt.args.s) + if (err != nil) != tt.wantErr { + t.Errorf("AuthorizeGRPC.Authorize() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("AuthorizeGRPC.Authorize() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/proxy/clients/clients.go b/proxy/clients/clients.go new file mode 100644 index 000000000..eba75c5e8 --- /dev/null +++ b/proxy/clients/clients.go @@ -0,0 +1,106 @@ +package clients // import "github.com/pomerium/pomerium/proxy/clients" + +import ( + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io/ioutil" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/middleware" +) + +const defaultGRPCPort = 443 + +// Options contains options for connecting to a pomerium rpc service. +type Options struct { + // Addr is the location of the authenticate service. e.g. "service.corp.example:8443" + Addr string + // InternalAddr is the internal (behind the ingress) address to use when + // making a connection. If empty, Addr is used. + InternalAddr string + // OverrideCertificateName overrides the server name used to verify the hostname on the + // returned certificates from the server. gRPC internals also use it to override the virtual + // hosting name if it is set. + OverrideCertificateName string + // Shared secret is used to authenticate a authenticate-client with a authenticate-server. + SharedSecret string + // CA specifies the base64 encoded TLS certificate authority to use. + CA string + // CAFile specifies the TLS certificate authority file to use. + CAFile string +} + +// NewGRPCClientConn returns a new gRPC pomerium service client connection. +func NewGRPCClientConn(opts *Options) (*grpc.ClientConn, error) { + // gRPC uses a pre-shared secret middleware to establish authentication b/w server and client + if opts.SharedSecret == "" { + return nil, errors.New("proxy/clients: grpc client requires shared secret") + } + grpcAuth := middleware.NewSharedSecretCred(opts.SharedSecret) + + var connAddr string + if opts.InternalAddr != "" { + connAddr = opts.InternalAddr + } else { + connAddr = opts.Addr + } + if connAddr == "" { + return nil, errors.New("proxy/clients: connection address required") + } + // no colon exists in the connection string, assume one must be added manually + if !strings.Contains(connAddr, ":") { + connAddr = fmt.Sprintf("%s:%d", connAddr, defaultGRPCPort) + } + + var cp *x509.CertPool + if opts.CA != "" || opts.CAFile != "" { + cp = x509.NewCertPool() + var ca []byte + var err error + if opts.CA != "" { + ca, err = base64.StdEncoding.DecodeString(opts.CA) + if err != nil { + return nil, fmt.Errorf("failed to decode certificate authority: %v", err) + } + } else { + ca, err = ioutil.ReadFile(opts.CAFile) + if err != nil { + return nil, fmt.Errorf("certificate authority file %v not readable: %v", opts.CAFile, err) + } + } + if ok := cp.AppendCertsFromPEM(ca); !ok { + return nil, fmt.Errorf("failed to append CA cert to certPool") + } + } else { + newCp, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + cp = newCp + } + + log.Info(). + Str("OverrideCertificateName", opts.OverrideCertificateName). + Str("addr", connAddr).Msgf("proxy/clients: grpc connection") + cert := credentials.NewTLS(&tls.Config{RootCAs: cp}) + + // override allowed certificate name string, typically used when doing behind ingress connection + if opts.OverrideCertificateName != "" { + err := cert.OverrideServerName(opts.OverrideCertificateName) + if err != nil { + return nil, err + } + } + return grpc.Dial( + connAddr, + grpc.WithTransportCredentials(cert), + grpc.WithPerRPCCredentials(grpcAuth), + ) +} diff --git a/proxy/authenticator/mock_authenticator.go b/proxy/clients/mock_clients.go similarity index 65% rename from proxy/authenticator/mock_authenticator.go rename to proxy/clients/mock_clients.go index 806559ecd..88993d905 100644 --- a/proxy/authenticator/mock_authenticator.go +++ b/proxy/clients/mock_clients.go @@ -1,4 +1,4 @@ -package authenticator // import "github.com/pomerium/pomerium/proxy/authenticator" +package clients // import "github.com/pomerium/pomerium/proxy/clients" import ( "context" @@ -34,3 +34,18 @@ func (a MockAuthenticate) Validate(ctx context.Context, idToken string) (bool, e // Close is a mocked authenticator client function. func (a MockAuthenticate) Close() error { return a.CloseError } + +// MockAuthorize provides a mocked implementation of the authorizer interface. +type MockAuthorize struct { + AuthorizeResponse bool + AuthorizeError error + CloseError error +} + +// Close is a mocked authorizer client function. +func (a MockAuthorize) Close() error { return a.CloseError } + +// Authorize is a mocked authorizer client function. +func (a MockAuthorize) Authorize(ctx context.Context, route string, s *sessions.SessionState) (bool, error) { + return a.AuthorizeResponse, a.AuthorizeError +} diff --git a/proxy/authenticator/authenticator_test.go b/proxy/clients/mock_clients_test.go similarity index 73% rename from proxy/authenticator/authenticator_test.go rename to proxy/clients/mock_clients_test.go index 66de38d43..127dfc967 100644 --- a/proxy/authenticator/authenticator_test.go +++ b/proxy/clients/mock_clients_test.go @@ -1,4 +1,4 @@ -package authenticator +package clients import ( "context" @@ -59,24 +59,3 @@ func TestMockAuthenticate(t *testing.T) { } } - -func TestNew(t *testing.T) { - tests := []struct { - name string - serviceName string - opts *Options - wantErr bool - }{ - {"grpc good", "grpc", &Options{Addr: "test", InternalAddr: "intranet.local", SharedSecret: "secret"}, false}, - {"grpc missing shared secret", "grpc", &Options{Addr: "test", InternalAddr: "intranet.local", SharedSecret: ""}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := New(tt.serviceName, tt.opts) - if (err != nil) != tt.wantErr { - t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) - return - } - }) - } -} diff --git a/proxy/authenticator/testdata/example.crt b/proxy/clients/testdata/example.crt similarity index 100% rename from proxy/authenticator/testdata/example.crt rename to proxy/clients/testdata/example.crt diff --git a/proxy/handlers.go b/proxy/handlers.go index d26fffe8e..1391100a3 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -19,7 +19,7 @@ import ( ) var ( - // ErrUserNotAuthorized is set when user is not authorized to access a resource + // ErrUserNotAuthorized is set when user is not authorized to access a resource. ErrUserNotAuthorized = errors.New("user not authorized") ) @@ -43,7 +43,7 @@ func (p *Proxy) Handler() http.Handler { mux.HandleFunc("/robots.txt", p.RobotsTxt) mux.HandleFunc("/.pomerium/sign_out", p.SignOut) mux.HandleFunc("/.pomerium/callback", p.OAuthCallback) - // mux.HandleFunc("/.pomerium/refresh", p.Refresh) //todo(bdd): needs DoS protection before inclusion + // mux.HandleFunc("/.pomerium/refresh", p.Refresh) //todo(bdd): needs DoS protection before inclusion mux.HandleFunc("/", p.Proxy) // middleware chain @@ -242,9 +242,18 @@ func (p *Proxy) Proxy(w http.ResponseWriter, r *http.Request) { return } } - - // todo(bdd): add authorization service validation - + // remove dupe session call + session, err := p.sessionStore.LoadSession(r) + if err != nil { + p.sessionStore.ClearSession(w, r) + return + } + authorized, err := p.AuthorizeClient.Authorize(r.Context(), r.Host, session) + if !authorized || err != nil { + log.FromRequest(r).Warn().Err(err).Msg("proxy: user unauthorized") + httputil.ErrorResponse(w, r, "Access unauthorized", http.StatusForbidden) + return + } // We have validated the users request and now proxy their request to the provided upstream. route, ok := p.router(r) if !ok { @@ -279,7 +288,7 @@ func (p *Proxy) Proxy(w http.ResponseWriter, r *http.Request) { // httputil.ErrorResponse(w, r, err.Error(), http.StatusInternalServerError) // return // } -// fmt.Fprintf(w, string(jsonSession)) +// fmt.Fprint(w, string(jsonSession)) // } // Authenticate authenticates a request by checking for a session cookie, and validating its expiration, diff --git a/proxy/handlers_test.go b/proxy/handlers_test.go index 263b208ef..e4995da90 100644 --- a/proxy/handlers_test.go +++ b/proxy/handlers_test.go @@ -1,6 +1,7 @@ package proxy import ( + "encoding/base64" "errors" "fmt" "net/http" @@ -13,7 +14,7 @@ import ( "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/version" - "github.com/pomerium/pomerium/proxy/authenticator" + "github.com/pomerium/pomerium/proxy/clients" ) type mockCipher struct{} @@ -152,13 +153,11 @@ func TestProxy_Favicon(t *testing.T) { t.Fatal(err) } req := httptest.NewRequest("GET", "/favicon.ico", nil) - rr := httptest.NewRecorder() proxy.Favicon(rr, req) if status := rr.Code; status != http.StatusNotFound { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusNotFound) } - // todo(bdd) : good way of mocking auth then serving a simple favicon? } func TestProxy_Signout(t *testing.T) { @@ -190,7 +189,7 @@ func TestProxy_OAuthStart(t *testing.T) { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusFound) } // expected url - expected := `