diff --git a/3RD-PARTY b/3RD-PARTY index 8b1bc93eb..a3a7e0289 100644 --- a/3RD-PARTY +++ b/3RD-PARTY @@ -105,6 +105,30 @@ https://github.com/justinas/alice/blob/master/LICENSE The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +goji +SPDX-License-Identifier: MIT +https://github.com/zenazn/goji/blob/master/LICENSE + + Copyright (c) 2014, 2015, 2016 Carl Jackson (carl@avtok.com) + MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go index 1228b21e5..01ee92df8 100644 --- a/authenticate/authenticate.go +++ b/authenticate/authenticate.go @@ -18,18 +18,19 @@ import ( ) var defaultOptions = &Options{ - CookieName: "_pomerium_authenticate", - CookieHTTPOnly: true, - CookieExpire: time.Duration(168) * time.Hour, - CookieRefresh: time.Duration(1) * time.Hour, - SessionLifetimeTTL: time.Duration(720) * time.Hour, - Scopes: []string{"openid", "email", "profile"}, + CookieName: "_pomerium_authenticate", + CookieHTTPOnly: true, + CookieSecure: true, + CookieExpire: time.Duration(168) * time.Hour, + CookieRefresh: time.Duration(30) * time.Minute, + CookieLifetimeTTL: time.Duration(720) * time.Hour, } -// Options permits the configuration of the authentication service +// Options details the available configuration settings for the authenticate service type Options struct { RedirectURL *url.URL `envconfig:"REDIRECT_URL"` + // SharedKey is used to authenticate requests between services SharedKey string `envconfig:"SHARED_SECRET"` // Coarse authorization based on user email domain @@ -37,27 +38,25 @@ type Options struct { ProxyRootDomains []string `envconfig:"PROXY_ROOT_DOMAIN"` // Session/Cookie management - CookieName string - CookieSecret string `envconfig:"COOKIE_SECRET"` - CookieDomain string `envconfig:"COOKIE_DOMAIN"` - CookieExpire time.Duration `envconfig:"COOKIE_EXPIRE"` - CookieRefresh time.Duration `envconfig:"COOKIE_REFRESH"` - CookieSecure bool `envconfig:"COOKIE_SECURE"` - CookieHTTPOnly bool `envconfig:"COOKIE_HTTP_ONLY"` + CookieName string + CookieSecret string `envconfig:"COOKIE_SECRET"` + CookieDomain string `envconfig:"COOKIE_DOMAIN"` + CookieSecure bool `envconfig:"COOKIE_SECURE"` + CookieHTTPOnly bool `envconfig:"COOKIE_HTTP_ONLY"` + CookieExpire time.Duration `envconfig:"COOKIE_EXPIRE"` + CookieRefresh time.Duration `envconfig:"COOKIE_REFRESH"` + CookieLifetimeTTL time.Duration `envconfig:"COOKIE_LIFETIME"` - SessionLifetimeTTL time.Duration `envconfig:"SESSION_LIFETIME_TTL"` - - // Authentication provider configuration variables as specified by RFC6749 + // IdentityProvider provider configuration variables as specified by RFC6749 // See: https://openid.net/specs/openid-connect-basic-1_0.html#RFC6749 ClientID string `envconfig:"IDP_CLIENT_ID"` ClientSecret string `envconfig:"IDP_CLIENT_SECRET"` Provider string `envconfig:"IDP_PROVIDER"` ProviderURL string `envconfig:"IDP_PROVIDER_URL"` - Scopes []string `envconfig:"IDP_SCOPE"` + Scopes []string `envconfig:"IDP_SCOPES"` } -// OptionsFromEnvConfig builds the authentication service's configuration -// options from provided environmental variables +// OptionsFromEnvConfig builds the authenticate service's configuration environmental variables func OptionsFromEnvConfig() (*Options, error) { o := defaultOptions if err := envconfig.Process("", o); err != nil { @@ -66,7 +65,7 @@ func OptionsFromEnvConfig() (*Options, error) { return o, nil } -// Validate checks to see if configuration values are valid for the authentication service. +// Validate checks to see if configuration values are valid for the authenticate service. // The checks do not modify the internal state of the Option structure. Returns // on first error found. func (o *Options) Validate() error { @@ -102,8 +101,7 @@ func (o *Options) Validate() error { return nil } -// Authenticate is service for validating user authentication for proxied-requests -// against third-party identity provider (IdP) services. +// Authenticate validates a user's identity type Authenticate struct { RedirectURL *url.URL @@ -115,7 +113,7 @@ type Authenticate struct { SharedKey string - SessionLifetimeTTL time.Duration + CookieLifetimeTTL time.Duration templates *template.Template csrfStore sessions.CSRFStore @@ -125,7 +123,7 @@ type Authenticate struct { provider providers.Provider } -// New validates and creates a new authentication service from a configuration options. +// New validates and creates a new authenticate service from a set of Options func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate, error) { if opts == nil { return nil, errors.New("options cannot be nil") @@ -133,15 +131,9 @@ func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate if err := opts.Validate(); err != nil { return nil, err } - decodedAuthCodeSecret, err := base64.StdEncoding.DecodeString(opts.CookieSecret) - if err != nil { - return nil, err - } - cipher, err := cryptutil.NewCipher([]byte(decodedAuthCodeSecret)) - if err != nil { - return nil, err - } - decodedCookieSecret, err := base64.StdEncoding.DecodeString(opts.CookieSecret) + // checked by validate + decodedCookieSecret, _ := base64.StdEncoding.DecodeString(opts.CookieSecret) + cipher, err := cryptutil.NewCipher([]byte(decodedCookieSecret)) if err != nil { return nil, err } @@ -183,25 +175,22 @@ func New(opts *Options, optionFuncs ...func(*Authenticate) error) (*Authenticate return nil, err } } + return p, nil } func newProvider(opts *Options) (providers.Provider, error) { - pd := &providers.ProviderData{ + pd := &providers.IdentityProvider{ RedirectURL: opts.RedirectURL, ProviderName: opts.Provider, ProviderURL: opts.ProviderURL, ClientID: opts.ClientID, ClientSecret: opts.ClientSecret, - SessionLifetimeTTL: opts.SessionLifetimeTTL, + SessionLifetimeTTL: opts.CookieLifetimeTTL, Scopes: opts.Scopes, } np, err := providers.New(opts.Provider, pd) - if err != nil { - return nil, err - } - return providers.NewSingleFlightProvider(np), nil - + return np, err } func dotPrependDomains(d []string) []string { diff --git a/authenticate/authenticate_test.go b/authenticate/authenticate_test.go index 43ce70aa4..9c16229f6 100644 --- a/authenticate/authenticate_test.go +++ b/authenticate/authenticate_test.go @@ -8,23 +8,19 @@ import ( "time" ) -func init() { - os.Clearenv() -} - 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", - ClientSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=", - CookieSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=", - CookieRefresh: time.Duration(1) * time.Hour, - SessionLifetimeTTL: time.Duration(720) * time.Hour, - CookieExpire: time.Duration(168) * time.Hour, + ProxyRootDomains: []string{"example.com"}, + AllowedDomains: []string{"example.com"}, + RedirectURL: redirectURL, + SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=", + ClientID: "test-client-id", + ClientSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=", + CookieSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=", + CookieRefresh: time.Duration(1) * time.Hour, + CookieLifetimeTTL: time.Duration(720) * time.Hour, + CookieExpire: time.Duration(168) * time.Hour, } } @@ -81,6 +77,8 @@ func TestOptions_Validate(t *testing.T) { } func TestOptionsFromEnvConfig(t *testing.T) { + os.Clearenv() + tests := []struct { name string want *Options @@ -91,7 +89,7 @@ func TestOptionsFromEnvConfig(t *testing.T) { {"good default, no env settings", defaultOptions, "", "", false}, {"bad url", nil, "REDIRECT_URL", "%.rjlw", true}, {"good duration", defaultOptions, "COOKIE_EXPIRE", "1m", false}, - {"bad duration", nil, "COOKIE_EXPIRE", "1sm", true}, + {"bad duration", nil, "COOKIE_REFRESH", "1sm", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -131,3 +129,65 @@ func Test_dotPrependDomains(t *testing.T) { }) } } + +func Test_newProvider(t *testing.T) { + redirectURL, _ := url.Parse("https://example.com/oauth3/callback") + + goodOpts := &Options{ + RedirectURL: redirectURL, + Provider: "google", + ProviderURL: "", + ClientID: "cllient-id", + ClientSecret: "client-secret", + } + tests := []struct { + name string + opts *Options + wantErr bool + }{ + {"good", goodOpts, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := newProvider(tt.opts) + if (err != nil) != tt.wantErr { + t.Errorf("newProvider() error = %v, wantErr %v", err, tt.wantErr) + return + } + // if !reflect.DeepEqual(got, tt.want) { + // t.Errorf("newProvider() = %v, want %v", got, tt.want) + // } + }) + } +} + +func TestNew(t *testing.T) { + good := testOptions() + good.Provider = "google" + + badRedirectURL := testOptions() + badRedirectURL.RedirectURL = nil + + tests := []struct { + name string + opts *Options + // want *Authenticate + wantErr bool + }{ + {"good", good, false}, + {"empty opts", nil, true}, + {"fails to validate", badRedirectURL, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := New(tt.opts) + 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/authenticate/grpc.go b/authenticate/grpc.go new file mode 100644 index 000000000..8ae2b3eff --- /dev/null +++ b/authenticate/grpc.go @@ -0,0 +1,64 @@ +package authenticate // import "github.com/pomerium/pomerium/authenticate" +import ( + "context" + "fmt" + + "github.com/golang/protobuf/ptypes" + + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/sessions" + pb "github.com/pomerium/pomerium/proto/authenticate" +) + +// Authenticate takes an encrypted code, and returns the authentication result. +func (p *Authenticate) Authenticate(ctx context.Context, in *pb.AuthenticateRequest) (*pb.AuthenticateReply, error) { + session, err := sessions.UnmarshalSession(in.Code, p.cipher) + if err != nil { + return nil, fmt.Errorf("authenticate/grpc: %v", err) + } + expiryTimestamp, err := ptypes.TimestampProto(session.RefreshDeadline) + if err != nil { + return nil, err + } + + return &pb.AuthenticateReply{ + AccessToken: session.AccessToken, + RefreshToken: session.RefreshToken, + IdToken: session.IDToken, + User: session.User, + Email: session.Email, + Expiry: expiryTimestamp, + }, nil +} + +// 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(in.IdToken) + if err != nil { + return &pb.ValidateReply{IsValid: false}, err + } + return &pb.ValidateReply{IsValid: isValid}, nil +} + +// Refresh renews a user's session checks if the session has been revoked using an access token +// without reprompting the user. +func (p *Authenticate) Refresh(ctx context.Context, in *pb.RefreshRequest) (*pb.RefreshReply, error) { + newToken, err := p.provider.Refresh(in.RefreshToken) + if err != nil { + return nil, err + } + expiryTimestamp, err := ptypes.TimestampProto(newToken.Expiry) + if err != nil { + return nil, err + } + log.Info(). + Str("session.AccessToken", newToken.AccessToken). + Msg("authenticate: grpc: refresh: ok") + + return &pb.RefreshReply{ + AccessToken: newToken.AccessToken, + Expiry: expiryTimestamp, + }, nil + +} diff --git a/authenticate/grpc_test.go b/authenticate/grpc_test.go new file mode 100644 index 000000000..3551379ff --- /dev/null +++ b/authenticate/grpc_test.go @@ -0,0 +1,170 @@ +package authenticate + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/golang/protobuf/ptypes" + "github.com/pomerium/pomerium/internal/cryptutil" + "github.com/pomerium/pomerium/internal/sessions" + pb "github.com/pomerium/pomerium/proto/authenticate" + "golang.org/x/oauth2" +) + +var fixedDate = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) + +// TestProvider is a mock provider +type testProvider struct{} + +func (tp *testProvider) Authenticate(s string) (*sessions.SessionState, error) { + return &sessions.SessionState{}, nil +} + +func (tp *testProvider) Revoke(s string) error { return nil } +func (tp *testProvider) GetSignInURL(s string) string { return "/signin" } +func (tp *testProvider) Refresh(s string) (*oauth2.Token, error) { + if s == "error" { + return nil, errors.New("failed refresh") + } + if s == "bad time" { + return &oauth2.Token{AccessToken: "updated", Expiry: time.Time{}}, nil + } + return &oauth2.Token{AccessToken: "updated", Expiry: fixedDate}, nil +} +func (tp *testProvider) Validate(token string) (bool, error) { + if token == "good" { + return true, nil + } else if token == "error" { + return false, errors.New("error validating id token") + } + return false, nil +} + +func TestAuthenticate_Validate(t *testing.T) { + tests := []struct { + name string + idToken string + want bool + wantErr bool + }{ + {"good", "example", false, false}, + {"error", "error", false, true}, + {"not error", "not error", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tp := &testProvider{} + p := &Authenticate{provider: tp} + got, err := p.Validate(context.Background(), &pb.ValidateRequest{IdToken: tt.idToken}) + if (err != nil) != tt.wantErr { + t.Errorf("Authenticate.Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got.IsValid, tt.want) { + t.Errorf("Authenticate.Validate() = %v, want %v", got.IsValid, tt.want) + } + }) + } +} + +func TestAuthenticate_Refresh(t *testing.T) { + fixedProtoTime, err := ptypes.TimestampProto(fixedDate) + if err != nil { + t.Fatal("failed to parse timestamp") + } + + tests := []struct { + name string + refreshToken string + want *pb.RefreshReply + wantErr bool + }{ + {"good", "refresh-token", &pb.RefreshReply{AccessToken: "updated", Expiry: fixedProtoTime}, false}, + {"test error", "error", nil, true}, + // {"test bad time", "bad time", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tp := &testProvider{} + p := &Authenticate{provider: tp} + + got, err := p.Refresh(context.Background(), &pb.RefreshRequest{RefreshToken: tt.refreshToken}) + if (err != nil) != tt.wantErr { + t.Errorf("Authenticate.Refresh() error = %v, wantErr %v", err, tt.wantErr) + + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Authenticate.Refresh() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAuthenticate_Authenticate(t *testing.T) { + secret := cryptutil.GenerateKey() + c, err := cryptutil.NewCipher([]byte(secret)) + if err != nil { + t.Fatalf("expected to be able to create cipher: %v", err) + } + newSecret := cryptutil.GenerateKey() + c2, err := cryptutil.NewCipher([]byte(newSecret)) + if err != nil { + t.Fatalf("expected to be able to create cipher: %v", err) + } + lt := time.Now().Add(1 * time.Hour).Truncate(time.Second).UTC() + rt := time.Now().Add(1 * time.Hour).Truncate(time.Second).UTC() + vt := time.Now().Add(1 * time.Minute).Truncate(time.Second).UTC() + vtProto, err := ptypes.TimestampProto(rt) + if err != nil { + t.Fatal("failed to parse timestamp") + } + + want := &sessions.SessionState{ + AccessToken: "token1234", + RefreshToken: "refresh4321", + LifetimeDeadline: lt, + RefreshDeadline: rt, + ValidDeadline: vt, + Email: "user@domain.com", + User: "user", + } + + goodReply := &pb.AuthenticateReply{ + AccessToken: "token1234", + RefreshToken: "refresh4321", + Expiry: vtProto, + Email: "user@domain.com", + User: "user"} + ciphertext, err := sessions.MarshalSession(want, c) + if err != nil { + t.Fatalf("expected to be encode session: %v", err) + } + + tests := []struct { + name string + cipher cryptutil.Cipher + code string + want *pb.AuthenticateReply + wantErr bool + }{ + {"good", c, ciphertext, goodReply, false}, + {"bad cipher", c2, ciphertext, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Authenticate{cipher: tt.cipher} + got, err := p.Authenticate(context.Background(), &pb.AuthenticateRequest{Code: tt.code}) + if (err != nil) != tt.wantErr { + t.Errorf("Authenticate.Authenticate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Authenticate.Authenticate() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/authenticate/handlers.go b/authenticate/handlers.go index be7599007..9644c1970 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -2,7 +2,6 @@ package authenticate // import "github.com/pomerium/pomerium/authenticate" import ( "encoding/base64" - "encoding/json" "fmt" "net/http" "net/url" @@ -17,16 +16,19 @@ import ( "github.com/pomerium/pomerium/internal/version" ) +// securityHeaders corresponds to HTTP response headers related to security. +// https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#tab=Headers var securityHeaders = map[string]string{ "Strict-Transport-Security": "max-age=31536000", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "X-XSS-Protection": "1; mode=block", - "Content-Security-Policy": "default-src 'none'; style-src 'self' 'sha256-pSTVzZsFAqd2U3QYu+BoBDtuJWaPM/+qMy/dBRrhb5Y='; img-src 'self';", - "Referrer-Policy": "Same-origin", + "Content-Security-Policy": "default-src 'none'; style-src 'self' " + + "'sha256-pSTVzZsFAqd2U3QYu+BoBDtuJWaPM/+qMy/dBRrhb5Y='; img-src 'self';", + "Referrer-Policy": "Same-origin", } -// Handler returns the Http.Handlers for authentication, callback, and refresh +// Handler returns the Http.Handlers for authenticate, callback, and refresh func (p *Authenticate) Handler() http.Handler { // set up our standard middlewares stdMiddleware := middleware.NewChain() @@ -52,8 +54,6 @@ func (p *Authenticate) Handler() http.Handler { middleware.ValidateSignature(p.SharedKey), middleware.ValidateRedirectURI(p.ProxyRootDomains)) - validateClientSecretMiddleware := stdMiddleware.Append(middleware.ValidateClientSecret(p.SharedKey)) - mux := http.NewServeMux() mux.Handle("/robots.txt", stdMiddleware.ThenFunc(p.RobotsTxt)) // Identity Provider (IdP) callback endpoints and callbacks @@ -61,11 +61,7 @@ func (p *Authenticate) Handler() http.Handler { mux.Handle("/oauth2/callback", stdMiddleware.ThenFunc(p.OAuthCallback)) // authenticate-server endpoints mux.Handle("/sign_in", validateSignatureMiddleware.ThenFunc(p.SignIn)) - mux.Handle("/sign_out", validateSignatureMiddleware.ThenFunc(p.SignOut)) // "GET", "POST" - mux.Handle("/profile", validateClientSecretMiddleware.ThenFunc(p.GetProfile)) // GET - mux.Handle("/validate", validateClientSecretMiddleware.ThenFunc(p.ValidateToken)) // GET - mux.Handle("/redeem", validateClientSecretMiddleware.ThenFunc(p.Redeem)) // POST - mux.Handle("/refresh", validateClientSecretMiddleware.ThenFunc(p.Refresh)) //POST + mux.Handle("/sign_out", validateSignatureMiddleware.ThenFunc(p.SignOut)) // GET POST return mux } @@ -76,43 +72,15 @@ func (p *Authenticate) RobotsTxt(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "User-agent: *\nDisallow: /") } -// SignInPage directs the user to the sign in page. Takes a `redirect_uri` param. -func (p *Authenticate) SignInPage(w http.ResponseWriter, r *http.Request) { - redirectURL := p.RedirectURL.ResolveReference(r.URL) - - destinationURL, _ := url.Parse(redirectURL.Query().Get("redirect_uri")) - t := struct { - ProviderName string - AllowedDomains []string - Redirect string - Destination string - Version string - }{ - ProviderName: p.provider.Data().ProviderName, - AllowedDomains: p.AllowedDomains, - Redirect: redirectURL.String(), - Destination: destinationURL.Host, - Version: version.FullVersion(), - } - log.FromRequest(r).Debug(). - Str("ProviderName", p.provider.Data().ProviderName). - Str("Redirect", redirectURL.String()). - Str("Destination", destinationURL.Host). - Str("AllowedDomains", strings.Join(p.AllowedDomains, ", ")). - Msg("authenticate: SignInPage") - w.WriteHeader(http.StatusOK) - p.templates.ExecuteTemplate(w, "sign_in.html", t) -} - func (p *Authenticate) authenticate(w http.ResponseWriter, r *http.Request) (*sessions.SessionState, error) { session, err := p.sessionStore.LoadSession(r) if err != nil { - log.Error().Err(err).Msg("authenticate: failed to load session") + log.FromRequest(r).Error().Err(err).Msg("authenticate: failed to load session") p.sessionStore.ClearSession(w, r) return nil, err } - // ensure sessions lifetime has not expired + // if long-lived lifetime has expired, clear session if session.LifetimePeriodExpired() { log.FromRequest(r).Warn().Msg("authenticate: lifetime expired") p.sessionStore.ClearSession(w, r) @@ -120,18 +88,14 @@ func (p *Authenticate) authenticate(w http.ResponseWriter, r *http.Request) (*se } // check if session refresh period is up if session.RefreshPeriodExpired() { - ok, err := p.provider.RefreshSessionIfNeeded(session) + newToken, err := p.provider.Refresh(session.RefreshToken) if err != nil { log.FromRequest(r).Error().Err(err).Msg("authenticate: failed to refresh session") p.sessionStore.ClearSession(w, r) return nil, err } - if !ok { - log.FromRequest(r).Error().Msg("user unauthorized after refresh") - p.sessionStore.ClearSession(w, r) - return nil, httputil.ErrUserNotAuthorized - } - // update refresh'd session in cookie + session.AccessToken = newToken.AccessToken + session.RefreshDeadline = newToken.Expiry err = p.sessionStore.SaveSession(w, r, session) if err != nil { // We refreshed the session successfully, but failed to save it. @@ -143,9 +107,9 @@ func (p *Authenticate) authenticate(w http.ResponseWriter, r *http.Request) (*se } } else { // The session has not exceeded it's lifetime or requires refresh - ok := p.provider.ValidateSessionState(session) - if !ok { - log.FromRequest(r).Error().Msg("invalid session state") + ok, err := p.provider.Validate(session.IDToken) + if !ok || err != nil { + log.FromRequest(r).Error().Err(err).Msg("invalid session state") p.sessionStore.ClearSession(w, r) return nil, httputil.ErrUserNotAuthorized } @@ -157,68 +121,53 @@ func (p *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 !p.Validator(session.Email) { log.FromRequest(r).Error().Msg("invalid email user") return nil, httputil.ErrUserNotAuthorized } + log.Info().Msg("authenticate") return session, nil } // SignIn handles the /sign_in endpoint. It attempts to authenticate the user, // and if the user is not authenticated, it renders a sign in page. func (p *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { - // We attempt to authenticate the user. If they cannot be authenticated, we render a sign-in - // page. - // - // If the user is authenticated, we redirect back to the proxy application - // at the `redirect_uri`, with a temporary token. - // - // TODO: It is possible for a user to visit this page without a redirect destination. - // Should we allow the user to authenticate? If not, what should be the proposed workflow? - session, err := p.authenticate(w, r) switch err { case nil: // User is authenticated, redirect back to proxy p.ProxyOAuthRedirect(w, r, session) case http.ErrNoCookie, sessions.ErrLifetimeExpired, sessions.ErrInvalidSession: - log.Debug().Err(err).Msg("authenticate.SignIn") + log.Info().Err(err).Msg("authenticate.SignIn : expected failure") if err != http.ErrNoCookie { p.sessionStore.ClearSession(w, r) } p.OAuthStart(w, r) default: - log.Error().Err(err).Msg("authenticate.SignIn") + log.Error().Err(err).Msg("authenticate: unexpected sign in error") httputil.ErrorResponse(w, r, err.Error(), httputil.CodeForError(err)) } } -// ProxyOAuthRedirect redirects the user back to sso proxy's redirection endpoint. +// ProxyOAuthRedirect redirects the user back to proxy's redirection endpoint. +// This workflow corresponds to Section 3.1.2 of the OAuth2 RFC. +// See https://tools.ietf.org/html/rfc6749#section-3.1.2 for more specific information. func (p *Authenticate) ProxyOAuthRedirect(w http.ResponseWriter, r *http.Request, session *sessions.SessionState) { - // This workflow corresponds to Section 3.1.2 of the OAuth2 RFC. - // See https://tools.ietf.org/html/rfc6749#section-3.1.2 for more specific information. - // - // We redirect the user back to the proxy application's redirection endpoint; in the - // sso proxy, this is the `/oauth/callback` endpoint. - // - // We must provide the proxy with a temporary authorization code via the `code` parameter, - // which they can use to redeem an access token for subsequent API calls. - // - // We must also include the original `state` parameter received from the proxy application. - err := r.ParseForm() if err != nil { httputil.ErrorResponse(w, r, err.Error(), http.StatusInternalServerError) return } - + // original `state` parameter received from the proxy application. state := r.Form.Get("state") if state == "" { httputil.ErrorResponse(w, r, "no state parameter supplied", http.StatusForbidden) return } - + // redirect url of proxy-service redirectURI := r.Form.Get("redirect_uri") if redirectURI == "" { httputil.ErrorResponse(w, r, "no redirect_uri parameter supplied", http.StatusForbidden) @@ -230,7 +179,7 @@ func (p *Authenticate) ProxyOAuthRedirect(w http.ResponseWriter, r *http.Request httputil.ErrorResponse(w, r, "malformed redirect_uri parameter passed", http.StatusBadRequest) return } - + // encrypt session state as json blob encrypted, err := sessions.MarshalSession(session, p.cipher) if err != nil { httputil.ErrorResponse(w, r, err.Error(), http.StatusInternalServerError) @@ -267,6 +216,7 @@ func (p *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) { case nil: break case http.ErrNoCookie: // if there's no cookie in the session we can just redirect + log.Error().Err(err).Msg("authenticate.SignOut : no cookie") http.Redirect(w, r, redirectURI, http.StatusFound) return default: @@ -277,7 +227,7 @@ func (p *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) { return } - err = p.provider.Revoke(session) + err = p.provider.Revoke(session.AccessToken) if err != nil { log.Error().Err(err).Msg("authenticate.SignOut : error revoking session") p.SignOutPage(w, r, "An error occurred during sign out. Please try again.") @@ -299,10 +249,11 @@ func (p *Authenticate) SignOutPage(w http.ResponseWriter, r *http.Request, messa signature := r.Form.Get("sig") timestamp := r.Form.Get("ts") - destinationURL, _ := url.Parse(redirectURI) //checked by middleware + destinationURL, err := url.Parse(redirectURI) // An error message indicates that an internal server error occurred - if message != "" { + if message != "" || err != nil { + log.Error().Err(err).Msg("authenticate.SignOutPage") w.WriteHeader(http.StatusInternalServerError) } @@ -326,8 +277,8 @@ func (p *Authenticate) SignOutPage(w http.ResponseWriter, r *http.Request, messa p.templates.ExecuteTemplate(w, "sign_out.html", t) } -// OAuthStart starts the authentication process by redirecting to the provider. It provides a -// `redirectURI`, allowing the provider to redirect back to the sso proxy after authentication. +// OAuthStart starts the authenticate process by redirecting to the provider. It provides a +// `redirectURI`, allowing the provider to redirect back to the sso proxy after authenticate. func (p *Authenticate) OAuthStart(w http.ResponseWriter, r *http.Request) { authRedirectURL, err := url.Parse(r.URL.Query().Get("redirect_uri")) if err != nil { @@ -339,12 +290,12 @@ func (p *Authenticate) OAuthStart(w http.ResponseWriter, r *http.Request) { nonce := fmt.Sprintf("%x", cryptutil.GenerateKey()) p.csrfStore.SetCSRF(w, r, nonce) - // confirm the redirect uri is from the root domain + // verify redirect uri is from the root domain if !middleware.ValidRedirectURI(authRedirectURL.String(), p.ProxyRootDomains) { httputil.ErrorResponse(w, r, "Invalid redirect parameter", http.StatusBadRequest) return } - // confirm proxy url is from the root domain + // verify proxy url is from the root domain proxyRedirectURL, err := url.Parse(authRedirectURL.Query().Get("redirect_uri")) if err != nil || !middleware.ValidRedirectURI(proxyRedirectURL.String(), p.ProxyRootDomains) { httputil.ErrorResponse(w, r, "Invalid redirect parameter", http.StatusBadRequest) @@ -359,51 +310,41 @@ func (p *Authenticate) OAuthStart(w http.ResponseWriter, r *http.Request) { return } - // embed authenticate service's state as the base64'd nonce and authenticate callback url + // concat base64'd nonce and authenticate url to make state state := base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", nonce, authRedirectURL.String()))) + // build the provider sign in url signInURL := p.provider.GetSignInURL(state) http.Redirect(w, r, signInURL, http.StatusFound) } -func (p *Authenticate) redeemCode(host, code string) (*sessions.SessionState, error) { - session, err := p.provider.Redeem(code) - if err != nil { - return nil, err - } - // if session.Email == "" { - // return nil, fmt.Errorf("no email included in session") - // } - - return session, nil - -} - // getOAuthCallback completes the oauth cycle from an identity provider's callback func (p *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) (string, error) { - // finish the oauth cycle err := r.ParseForm() if err != nil { + log.FromRequest(r).Error().Err(err).Msg("authenticate: bad form on oauth callback") return "", httputil.HTTPError{Code: http.StatusInternalServerError, Message: err.Error()} } errorString := r.Form.Get("error") if errorString != "" { + log.FromRequest(r).Error().Err(err).Msg("authenticate: provider returned error") return "", httputil.HTTPError{Code: http.StatusForbidden, Message: errorString} } code := r.Form.Get("code") if code == "" { + log.FromRequest(r).Error().Err(err).Msg("authenticate: provider missing code") return "", httputil.HTTPError{Code: http.StatusBadRequest, Message: "Missing Code"} } - session, err := p.redeemCode(r.Host, code) + session, err := p.provider.Authenticate(code) if err != nil { - log.FromRequest(r).Error().Err(err).Msg("error redeeming authentication code") + log.FromRequest(r).Error().Err(err).Msg("authenticate: error redeeming authenticate code") return "", httputil.HTTPError{Code: http.StatusInternalServerError, Message: err.Error()} } bytes, err := base64.URLEncoding.DecodeString(r.Form.Get("state")) if err != nil { - log.FromRequest(r).Error().Err(err).Msg("failed decoding state") + log.FromRequest(r).Error().Err(err).Msg("authenticate: failed decoding state") return "", httputil.HTTPError{Code: http.StatusBadRequest, Message: "Couldn't decode state"} } s := strings.SplitN(string(bytes), ":", 2) @@ -414,11 +355,12 @@ func (p *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) redirect := s[1] c, err := p.csrfStore.GetCSRF(r) if err != nil { + log.FromRequest(r).Error().Err(err).Msg("authenticate: bad csrf") return "", httputil.HTTPError{Code: http.StatusForbidden, Message: "Missing CSRF token"} } p.csrfStore.ClearCSRF(w, r) if c.Value != nonce { - log.FromRequest(r).Error().Err(err).Msg("CSRF token mismatch") + log.FromRequest(r).Error().Err(err).Msg("authenticate: csrf mismatch") return "", httputil.HTTPError{Code: http.StatusForbidden, Message: "CSRF failed"} } @@ -427,13 +369,10 @@ func (p *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) } // Set cookie, or deny: validates the session email and group - // - for p.Validator see validator.go#newValidatorImpl for more info - // - for p.provider.ValidateGroup see providers/google.go#ValidateGroup for more info if !p.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"} } - log.FromRequest(r).Info().Str("email", session.Email).Msg("authentication complete") err = p.sessionStore.SaveSession(w, r, session) if err != nil { log.Error().Err(err).Msg("internal error") @@ -442,182 +381,22 @@ func (p *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) return redirect, nil } -// OAuthCallback handles the callback from the provider, and returns an error response if there is an error. -// If there is no error it will redirect to the redirect url. +// OAuthCallback handles the callback from the identity provider. Displays an error page if there +// was an error. If successful, redirects back to the proxy-service via the redirect-url. func (p *Authenticate) OAuthCallback(w http.ResponseWriter, r *http.Request) { redirect, err := p.getOAuthCallback(w, r) switch h := err.(type) { case nil: break case httputil.HTTPError: + log.Error().Err(err).Msg("authenticate: oauth callback error") httputil.ErrorResponse(w, r, h.Message, h.Code) return default: - log.Error().Err(err).Msg("authenticate.OAuthCallback") + log.Error().Err(err).Msg("authenticate: unexpected oauth callback error") httputil.ErrorResponse(w, r, "Internal Error", http.StatusInternalServerError) return } + // redirect back to the proxy-service http.Redirect(w, r, redirect, http.StatusFound) } - -// Redeem has a signed access token, and provides the user information associated with the access token. -func (p *Authenticate) Redeem(w http.ResponseWriter, r *http.Request) { - // The auth code is redeemed by the sso proxy for an access token, refresh token, - // expiration, and email. - err := r.ParseForm() - if err != nil { - http.Error(w, fmt.Sprintf("Bad Request: %s", err.Error()), http.StatusBadRequest) - return - } - - session, err := sessions.UnmarshalSession(r.Form.Get("code"), p.cipher) - if err != nil { - log.FromRequest(r).Error().Err(err).Msg("authenticate: failed to unmarshal session") - http.Error(w, fmt.Sprintf("invalid auth code: %s", err.Error()), http.StatusUnauthorized) - return - } - - if session == nil { - log.FromRequest(r).Error().Err(err).Msg("empty session") - http.Error(w, fmt.Sprintf("empty session: %s", err.Error()), http.StatusUnauthorized) - return - } - - if session != nil && (session.RefreshPeriodExpired() || session.LifetimePeriodExpired()) { - log.FromRequest(r).Error().Msg("expired session") - p.sessionStore.ClearSession(w, r) - http.Error(w, fmt.Sprintf("expired session"), http.StatusUnauthorized) - return - } - - response := struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` - ExpiresIn int64 `json:"expires_in"` - Email string `json:"email"` - }{ - AccessToken: session.AccessToken, - RefreshToken: session.RefreshToken, - IDToken: session.IDToken, - ExpiresIn: int64(time.Until(session.RefreshDeadline).Seconds()), - Email: session.Email, - } - - jsonBytes, err := json.Marshal(response) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - w.Header().Set("GAP-Auth", session.Email) - w.Header().Set("Content-Type", "application/json") - w.Write(jsonBytes) - -} - -// Refresh takes a refresh token and returns a new access token -func (p *Authenticate) Refresh(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - http.Error(w, fmt.Sprintf("Bad Request: %s", err.Error()), http.StatusBadRequest) - return - } - - refreshToken := r.Form.Get("refresh_token") - if refreshToken == "" { - http.Error(w, "Bad Request: No Refresh Token", http.StatusBadRequest) - return - } - - accessToken, expiresIn, err := p.provider.RefreshAccessToken(refreshToken) - if err != nil { - httputil.ErrorResponse(w, r, err.Error(), httputil.CodeForError(err)) - return - } - - response := struct { - AccessToken string `json:"access_token"` - ExpiresIn int64 `json:"expires_in"` - }{ - AccessToken: accessToken, - ExpiresIn: int64(expiresIn.Seconds()), - } - - bytes, err := json.Marshal(response) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusCreated) - w.Header().Set("Content-Type", "application/json") - w.Write(bytes) -} - -// GetProfile gets a list of groups of which a user is a member. -func (p *Authenticate) GetProfile(w http.ResponseWriter, r *http.Request) { - // The sso proxy sends the user's email to this endpoint to get a list of Google groups that - // the email is a member of. The proxy will compare these groups to the list of allowed - // groups for the upstream service the user is trying to access. - - email := r.FormValue("email") - if email == "" { - http.Error(w, "no email address included", http.StatusBadRequest) - return - } - - // groupsFormValue := r.FormValue("groups") - // allowedGroups := []string{} - // if groupsFormValue != "" { - // allowedGroups = strings.Split(groupsFormValue, ",") - // } - - // groups, err := p.provider.ValidateGroupMembership(email, allowedGroups) - // if err != nil { - // log.Error().Err(err).Msg("authenticate.GetProfile : error retrieving groups") - // httputil.ErrorResponse(w, r, err.Error(), httputil.CodeForError(err)) - // return - // } - - response := struct { - Email string `json:"email"` - }{ - Email: email, - } - - jsonBytes, err := json.Marshal(response) - if err != nil { - http.Error(w, fmt.Sprintf("error marshaling response: %s", err.Error()), http.StatusInternalServerError) - return - } - w.Header().Set("GAP-Auth", email) - w.Header().Set("Content-Type", "application/json") - w.Write(jsonBytes) -} - -// ValidateToken validates the X-Access-Token from the header and returns an error response -// if it's invalid -func (p *Authenticate) ValidateToken(w http.ResponseWriter, r *http.Request) { - accessToken := r.Header.Get("X-Access-Token") - idToken := r.Header.Get("X-Id-Token") - - if accessToken == "" { - w.WriteHeader(http.StatusBadRequest) - return - } - if idToken == "" { - w.WriteHeader(http.StatusBadRequest) - return - } - - ok := p.provider.ValidateSessionState(&sessions.SessionState{ - AccessToken: accessToken, - IDToken: idToken, - }) - - if !ok { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.WriteHeader(http.StatusOK) -} diff --git a/authenticate/handlers_test.go b/authenticate/handlers_test.go index 9b91c72e5..a99c90d59 100644 --- a/authenticate/handlers_test.go +++ b/authenticate/handlers_test.go @@ -1,14 +1,12 @@ package authenticate import ( - "bytes" "fmt" "net/http" "net/http/httptest" "net/url" "testing" - "github.com/pomerium/pomerium/authenticate/providers" "github.com/pomerium/pomerium/internal/templates" ) @@ -19,7 +17,6 @@ func testAuthenticate() *Authenticate { auth.AllowedDomains = []string{"*"} auth.ProxyRootDomains = []string{"example.com"} auth.templates = templates.New() - auth.provider = providers.NewTestProvider(auth.RedirectURL) return &auth } @@ -38,43 +35,5 @@ func TestAuthenticate_RobotsTxt(t *testing.T) { expected := fmt.Sprintf("User-agent: *\nDisallow: /") if rr.Body.String() != expected { t.Errorf("handler returned wrong body: got %v want %v", rr.Body.String(), expected) - - } -} - -func TestAuthenticate_SignInPage(t *testing.T) { - auth := testAuthenticate() - v := url.Values{} - v.Set("request_uri", "this-is-a-test-uri") - url := fmt.Sprintf("/signin?%s", v.Encode()) - - req, err := http.NewRequest("GET", url, nil) - - if err != nil { - t.Fatal(err) - } - rr := httptest.NewRecorder() - handler := http.HandlerFunc(auth.SignInPage) - handler.ServeHTTP(rr, req) - if status := rr.Code; status != http.StatusOK { - t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) - } - body := rr.Body.Bytes() - - tests := []struct { - name string - value string - want bool - }{ - {"provider name", auth.provider.Data().ProviderName, true}, - {"destination url", v.Encode(), true}, - {"shouldn't be found", "this string should not be in the body", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := bytes.Contains(body, []byte(tt.value)); got != tt.want { - t.Errorf("handler body missing expected value %v", tt.value) - } - }) } } diff --git a/authenticate/providers/doc.go b/authenticate/providers/doc.go new file mode 100644 index 000000000..2f1e9f108 --- /dev/null +++ b/authenticate/providers/doc.go @@ -0,0 +1,5 @@ +// Package providers implements OpenID Connect client logic for the set of supported identity +// providers. +// OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 RFC6749 protocol. +// https://openid.net/specs/openid-connect-core-1_0.html +package providers // import "github.com/pomerium/pomerium/internal/providers" diff --git a/authenticate/providers/gitlab.go b/authenticate/providers/gitlab.go index 842dd55d9..fb1db271b 100644 --- a/authenticate/providers/gitlab.go +++ b/authenticate/providers/gitlab.go @@ -15,7 +15,7 @@ const defaultGitlabProviderURL = "https://gitlab.com" // GitlabProvider is an implementation of the Provider interface. type GitlabProvider struct { - *ProviderData + *IdentityProvider cb *circuit.Breaker } @@ -32,7 +32,7 @@ type GitlabProvider struct { // - https://docs.gitlab.com/ee/integration/oauth_provider.html // - https://docs.gitlab.com/ee/api/oauth2.html // - https://gitlab.com/.well-known/openid-configuration -func NewGitlabProvider(p *ProviderData) (*GitlabProvider, error) { +func NewGitlabProvider(p *IdentityProvider) (*GitlabProvider, error) { ctx := context.Background() if p.ProviderURL == "" { p.ProviderURL = defaultGitlabProviderURL @@ -42,8 +42,9 @@ func NewGitlabProvider(p *ProviderData) (*GitlabProvider, error) { if err != nil { return nil, err } - p.Scopes = []string{oidc.ScopeOpenID, "read_user"} - + if len(p.Scopes) == 0 { + p.Scopes = []string{oidc.ScopeOpenID, "read_user"} + } p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, @@ -53,7 +54,7 @@ func NewGitlabProvider(p *ProviderData) (*GitlabProvider, error) { Scopes: p.Scopes, } gitlabProvider := &GitlabProvider{ - ProviderData: p, + IdentityProvider: p, } gitlabProvider.cb = circuit.NewBreaker(&circuit.Options{ HalfOpenConcurrentRequests: 2, diff --git a/authenticate/providers/google.go b/authenticate/providers/google.go index a0873b98d..9c145e810 100644 --- a/authenticate/providers/google.go +++ b/authenticate/providers/google.go @@ -11,7 +11,6 @@ import ( "github.com/pomerium/pomerium/authenticate/circuit" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/version" ) @@ -19,14 +18,14 @@ const defaultGoogleProviderURL = "https://accounts.google.com" // GoogleProvider is an implementation of the Provider interface. type GoogleProvider struct { - *ProviderData + *IdentityProvider cb *circuit.Breaker // non-standard oidc fields RevokeURL *url.URL } // NewGoogleProvider returns a new GoogleProvider and sets the provider url endpoints. -func NewGoogleProvider(p *ProviderData) (*GoogleProvider, error) { +func NewGoogleProvider(p *IdentityProvider) (*GoogleProvider, error) { ctx := context.Background() if p.ProviderURL == "" { @@ -37,18 +36,20 @@ func NewGoogleProvider(p *ProviderData) (*GoogleProvider, error) { if err != nil { return nil, err } - + if len(p.Scopes) == 0 { + p.Scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, ClientSecret: p.ClientSecret, Endpoint: p.provider.Endpoint(), RedirectURL: p.RedirectURL.String(), - Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + Scopes: p.Scopes, } googleProvider := &GoogleProvider{ - ProviderData: p, + IdentityProvider: p, } // google supports a revocation endpoint var claims struct { @@ -91,9 +92,9 @@ func (p *GoogleProvider) cbStateChange(from, to circuit.State) { // // https://developers.google.com/identity/protocols/OAuth2WebServer#tokenrevoke // https://github.com/googleapis/google-api-dotnet-client/issues/1285 -func (p *GoogleProvider) Revoke(s *sessions.SessionState) error { +func (p *GoogleProvider) Revoke(accessToken string) error { params := url.Values{} - params.Add("token", s.AccessToken) + params.Add("token", accessToken) err := httputil.Client("POST", p.RevokeURL.String(), version.UserAgent(), params, nil) if err != nil && err != httputil.ErrTokenRevoked { return err @@ -105,4 +106,5 @@ func (p *GoogleProvider) Revoke(s *sessions.SessionState) error { // Google requires access type offline func (p *GoogleProvider) GetSignInURL(state string) string { return p.oauth.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce) + } diff --git a/authenticate/providers/microsoft.go b/authenticate/providers/microsoft.go index 79637a380..92b92bb93 100644 --- a/authenticate/providers/microsoft.go +++ b/authenticate/providers/microsoft.go @@ -11,7 +11,6 @@ import ( "github.com/pomerium/pomerium/authenticate/circuit" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/version" ) @@ -22,7 +21,7 @@ const defaultAzureProviderURL = "https://login.microsoftonline.com/common" // AzureProvider is an implementation of the Provider interface type AzureProvider struct { - *ProviderData + *IdentityProvider cb *circuit.Breaker // non-standard oidc fields RevokeURL *url.URL @@ -31,7 +30,7 @@ type AzureProvider struct { // NewAzureProvider returns a new AzureProvider and sets the provider url endpoints. // If non-"common" tenant is desired, ProviderURL must be set. // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc -func NewAzureProvider(p *ProviderData) (*AzureProvider, error) { +func NewAzureProvider(p *IdentityProvider) (*AzureProvider, error) { ctx := context.Background() if p.ProviderURL == "" { @@ -43,18 +42,20 @@ func NewAzureProvider(p *ProviderData) (*AzureProvider, error) { if err != nil { return nil, err } - + if len(p.Scopes) == 0 { + p.Scopes = []string{oidc.ScopeOpenID, "profile", "email", "offline_access"} + } p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, ClientSecret: p.ClientSecret, Endpoint: p.provider.Endpoint(), RedirectURL: p.RedirectURL.String(), - Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + Scopes: p.Scopes, } azureProvider := &AzureProvider{ - ProviderData: p, + IdentityProvider: p, } // azure has a "end session endpoint" var claims struct { @@ -95,9 +96,9 @@ func (p *AzureProvider) cbStateChange(from, to circuit.State) { // Revoke revokes the access token a given session state. //https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request -func (p *AzureProvider) Revoke(s *sessions.SessionState) error { +func (p *AzureProvider) Revoke(token string) error { params := url.Values{} - params.Add("token", s.AccessToken) + params.Add("token", token) err := httputil.Client("POST", p.RevokeURL.String(), version.UserAgent(), params, nil) if err != nil && err != httputil.ErrTokenRevoked { return err diff --git a/authenticate/providers/oidc.go b/authenticate/providers/oidc.go index 336a544b9..5225be3ee 100644 --- a/authenticate/providers/oidc.go +++ b/authenticate/providers/oidc.go @@ -12,11 +12,11 @@ import ( // of an authorization identity provider. // see : https://openid.net/specs/openid-connect-core-1_0.html type OIDCProvider struct { - *ProviderData + *IdentityProvider } // NewOIDCProvider creates a new instance of an OpenID Connect provider. -func NewOIDCProvider(p *ProviderData) (*OIDCProvider, error) { +func NewOIDCProvider(p *IdentityProvider) (*OIDCProvider, error) { ctx := context.Background() if p.ProviderURL == "" { return nil, errors.New("missing required provider url") @@ -26,13 +26,16 @@ func NewOIDCProvider(p *ProviderData) (*OIDCProvider, error) { if err != nil { return nil, err } + if len(p.Scopes) == 0 { + p.Scopes = []string{oidc.ScopeOpenID, "profile", "email", "offline_access"} + } p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, ClientSecret: p.ClientSecret, Endpoint: p.provider.Endpoint(), RedirectURL: p.RedirectURL.String(), - Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + Scopes: p.Scopes, } - return &OIDCProvider{ProviderData: p}, nil + return &OIDCProvider{IdentityProvider: p}, nil } diff --git a/authenticate/providers/okta.go b/authenticate/providers/okta.go index a2c337fb8..efc390d26 100644 --- a/authenticate/providers/okta.go +++ b/authenticate/providers/okta.go @@ -9,21 +9,20 @@ import ( "golang.org/x/oauth2" "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/version" ) // OktaProvider provides a standard, OpenID Connect implementation // of an authorization identity provider. type OktaProvider struct { - *ProviderData + *IdentityProvider // non-standard oidc fields RevokeURL *url.URL } // NewOktaProvider creates a new instance of an OpenID Connect provider. -func NewOktaProvider(p *ProviderData) (*OktaProvider, error) { +func NewOktaProvider(p *IdentityProvider) (*OktaProvider, error) { ctx := context.Background() if p.ProviderURL == "" { return nil, errors.New("missing required provider url") @@ -33,24 +32,26 @@ func NewOktaProvider(p *ProviderData) (*OktaProvider, error) { if err != nil { return nil, err } + if len(p.Scopes) == 0 { + p.Scopes = []string{oidc.ScopeOpenID, "profile", "email", "offline_access"} + } p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, ClientSecret: p.ClientSecret, Endpoint: p.provider.Endpoint(), RedirectURL: p.RedirectURL.String(), - Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + Scopes: p.Scopes, } - oktaProvider := OktaProvider{ProviderData: p} // okta supports a revocation endpoint var claims struct { RevokeURL string `json:"revocation_endpoint"` } - if err := p.provider.Claims(&claims); err != nil { return nil, err } + oktaProvider := OktaProvider{IdentityProvider: p} oktaProvider.RevokeURL, err = url.Parse(claims.RevokeURL) if err != nil { @@ -61,11 +62,11 @@ func NewOktaProvider(p *ProviderData) (*OktaProvider, error) { // Revoke revokes the access token a given session state. // https://developer.okta.com/docs/api/resources/oidc#revoke -func (p *OktaProvider) Revoke(s *sessions.SessionState) error { +func (p *OktaProvider) Revoke(token string) error { params := url.Values{} params.Add("client_id", p.ClientID) params.Add("client_secret", p.ClientSecret) - params.Add("token", s.IDToken) + params.Add("token", token) params.Add("token_type_hint", "refresh_token") err := httputil.Client("POST", p.RevokeURL.String(), version.UserAgent(), params, nil) if err != nil && err != httputil.ErrTokenRevoked { @@ -73,3 +74,9 @@ func (p *OktaProvider) Revoke(s *sessions.SessionState) error { } return nil } + +// GetSignInURL returns the sign in url with typical oauth parameters +// Google requires access type offline +func (p *OktaProvider) GetSignInURL(state string) string { + return p.oauth.AuthCodeURL(state, oauth2.AccessTypeOffline) +} diff --git a/authenticate/providers/providers.go b/authenticate/providers/providers.go index b230bbc08..eef77f056 100644 --- a/authenticate/providers/providers.go +++ b/authenticate/providers/providers.go @@ -1,12 +1,11 @@ +//go:generate protoc -I ../../proto/authenticate --go_out=plugins=grpc:../../proto/authenticate ../../proto/authenticate/authenticate.proto + package providers // import "github.com/pomerium/pomerium/internal/providers" import ( "context" - "encoding/json" "errors" "fmt" - "io/ioutil" - "net/http" "net/url" "time" @@ -32,21 +31,17 @@ const ( // Provider is an interface exposing functions necessary to interact with a given provider. type Provider interface { - Data() *ProviderData - Redeem(string) (*sessions.SessionState, error) - ValidateSessionState(*sessions.SessionState) bool + Authenticate(string) (*sessions.SessionState, error) + Validate(string) (bool, error) + Refresh(string) (*oauth2.Token, error) + Revoke(string) error GetSignInURL(state string) string - RefreshSessionIfNeeded(*sessions.SessionState) (bool, error) - Revoke(*sessions.SessionState) error - RefreshAccessToken(string) (string, time.Duration, error) } // New returns a new identity provider based given its name. -// Returns an error if selected provided not found or if the provider fails to instantiate. -func New(provider string, pd *ProviderData) (Provider, error) { - var err error - var p Provider - switch provider { +// Returns an error if selected provided not found or if the identity provider is not known. +func New(providerName string, pd *IdentityProvider) (p Provider, err error) { + switch providerName { case AzureProviderName: p, err = NewAzureProvider(pd) case GitlabProviderName: @@ -58,7 +53,7 @@ func New(provider string, pd *ProviderData) (Provider, error) { case OktaProviderName: p, err = NewOktaProvider(pd) default: - return nil, fmt.Errorf("authenticate: provider %q not found", provider) + return nil, fmt.Errorf("authenticate: %q name not found", providerName) } if err != nil { return nil, err @@ -66,11 +61,13 @@ func New(provider string, pd *ProviderData) (Provider, error) { return p, nil } -// ProviderData holds the fields associated with providers -// necessary to implement the Provider interface. -type ProviderData struct { +// IdentityProvider contains the fields required for an OAuth 2.0 Authorization Request that +// requests that the End-User be authenticated by the Authorization Server. +// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +type IdentityProvider struct { + ProviderName string + RedirectURL *url.URL - ProviderName string ClientID string ClientSecret string ProviderURL string @@ -82,118 +79,50 @@ type ProviderData struct { oauth *oauth2.Config } -// Data returns a ProviderData. -func (p *ProviderData) Data() *ProviderData { return p } - -// GetSignInURL returns the sign in url with typical oauth parameters -func (p *ProviderData) GetSignInURL(state string) string { +// GetSignInURL returns a URL to OAuth 2.0 provider's consent page +// that asks for permissions for the required scopes explicitly. +// +// State is a token to protect the user from CSRF attacks. You must +// always provide a non-empty string and validate that it matches the +// the state query parameter on your redirect callback. +// See http://tools.ietf.org/html/rfc6749#section-10.12 for more info. +func (p *IdentityProvider) GetSignInURL(state string) string { return p.oauth.AuthCodeURL(state) } -// ValidateSessionState validates a given session's from it's JWT token +// Validate validates a given session's from it's JWT token // The function verifies it's been signed by the provider, preforms // any additional checks depending on the Config, and returns the payload. // -// ValidateSessionState does NOT do nonce validation. -func (p *ProviderData) ValidateSessionState(s *sessions.SessionState) bool { +// Validate does NOT do nonce validation. +// Validate does NOT check if revoked. +// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation +func (p *IdentityProvider) Validate(idToken string) (bool, error) { ctx := context.Background() - _, err := p.verifier.Verify(ctx, s.IDToken) + _, err := p.verifier.Verify(ctx, idToken) if err != nil { log.Error().Err(err).Msg("authenticate/providers: failed to verify session state") - return false + return false, err } - return true -} - -// Redeem creates a session with an identity provider from a authorization code -func (p *ProviderData) Redeem(code string) (*sessions.SessionState, error) { - ctx := context.Background() - // convert authorization code into a token - token, err := p.oauth.Exchange(ctx, code) - if err != nil { - return nil, fmt.Errorf("authenticate/providers: failed token exchange: %v", err) - } - s, err := p.createSessionState(ctx, token) - if err != nil { - return nil, fmt.Errorf("authenticate/providers: unable to update session: %v", err) - } - - // check if provider has info endpoint, try to hit that and gather more info - // especially useful if initial request did not contain email - // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo - var claims struct { - UserInfoURL string `json:"userinfo_endpoint"` - } - - if err := p.provider.Claims(&claims); err != nil || claims.UserInfoURL == "" { - log.Error().Err(err).Msg("authenticate/providers: failed retrieving userinfo_endpoint") - } else { - // userinfo endpoint found and valid - userInfo, err := p.UserInfo(ctx, claims.UserInfoURL, oauth2.StaticTokenSource(token)) - if err != nil { - return nil, fmt.Errorf("authenticate/providers: can't parse userinfo_endpoint: %v", err) - } - s.Email = userInfo.Email - } - - return s, nil -} - -// RefreshSessionIfNeeded will refresh the session state if it's deadline is expired -func (p *ProviderData) RefreshSessionIfNeeded(s *sessions.SessionState) (bool, error) { - if !sessionRefreshRequired(s) { - log.Debug().Msg("authenticate/providers: session refresh not needed") - return false, nil - } - origExpiration := s.RefreshDeadline - err := p.redeemRefreshToken(s) - if err != nil { - return false, fmt.Errorf("authenticate/providers: couldn't refresh token: %v", err) - } - - log.Debug().Time("NewDeadline", s.RefreshDeadline).Time("OldDeadline", origExpiration).Msgf("authenticate/providers refreshed") return true, nil } -func (p *ProviderData) redeemRefreshToken(s *sessions.SessionState) error { - log.Info().Msg("authenticate/providers.oidc.redeemRefreshToken 1") +// Authenticate creates a session with an identity provider from a authorization code +func (p *IdentityProvider) Authenticate(code string) (*sessions.SessionState, error) { ctx := context.Background() - t := &oauth2.Token{ - RefreshToken: s.RefreshToken, - Expiry: time.Now().Add(-time.Hour), - } - log.Info().Msg("authenticate/providers.oidc.redeemRefreshToken 3") - - // returns a TokenSource automatically refreshing it as necessary using the provided context - token, err := p.oauth.TokenSource(ctx, t).Token() + // convert authorization code into a token + oauth2Token, err := p.oauth.Exchange(ctx, code) if err != nil { - return fmt.Errorf("authenticate/providers: failed to get token: %v", err) + return nil, fmt.Errorf("authenticate/providers: failed token exchange: %v", err) } - log.Info().Msg("authenticate/providers.oidc.redeemRefreshToken 4") - - newSession, err := p.createSessionState(ctx, token) - if err != nil { - return fmt.Errorf("authenticate/providers: unable to update session: %v", err) - } - s.AccessToken = newSession.AccessToken - s.IDToken = newSession.IDToken - s.RefreshToken = newSession.RefreshToken - s.RefreshDeadline = newSession.RefreshDeadline - s.Email = newSession.Email - log.Info(). - Str("AccessToken", s.AccessToken). - Str("IdToken", s.IDToken). - Time("RefreshDeadline", s.RefreshDeadline). - Str("RefreshToken", s.RefreshToken). - Str("Email", s.Email). - Msg("authenticate/providers.redeemRefreshToken") + Str("RefreshToken", oauth2Token.RefreshToken). + Str("TokenType", oauth2Token.TokenType). + Str("AccessToken", oauth2Token.AccessToken). + Msg("Authenticate - oauth.Exchange") - return nil -} - -func (p *ProviderData) createSessionState(ctx context.Context, token *oauth2.Token) (*sessions.SessionState, error) { - rawIDToken, ok := token.Extra("id_token").(string) + //id_token contains claims about the authenticated user + rawIDToken, ok := oauth2Token.Extra("id_token").(string) if !ok { return nil, fmt.Errorf("token response did not contain an id_token") } @@ -204,142 +133,51 @@ func (p *ProviderData) createSessionState(ctx context.Context, token *oauth2.Tok return nil, fmt.Errorf("authenticate/providers: could not verify id_token: %v", err) } - // Extract custom claims. + // Extract id_token which contains claims about the authenticated user var claims struct { - Email string `json:"email"` - Verified *bool `json:"email_verified"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Groups []string `json:"groups"` } // parse claims from the raw, encoded jwt token if err := idToken.Claims(&claims); err != nil { return nil, fmt.Errorf("authenticate/providers: failed to parse id_token claims: %v", err) } - log.Debug(). - Str("AccessToken", token.AccessToken). - Str("IDToken", rawIDToken). - Str("claims.Email", claims.Email). - Str("RefreshToken", token.RefreshToken). - Str("idToken.Subject", idToken.Subject). - Str("idToken.Nonce", idToken.Nonce). - Str("RefreshDeadline", idToken.Expiry.String()). - Str("LifetimeDeadline", idToken.Expiry.String()). - Msg("authenticate/providers.createSessionState") return &sessions.SessionState{ - AccessToken: token.AccessToken, IDToken: rawIDToken, - RefreshToken: token.RefreshToken, - RefreshDeadline: idToken.Expiry, - LifetimeDeadline: idToken.Expiry, + AccessToken: oauth2Token.AccessToken, + RefreshToken: oauth2Token.RefreshToken, + RefreshDeadline: oauth2Token.Expiry, + LifetimeDeadline: sessions.ExtendDeadline(p.SessionLifetimeTTL), Email: claims.Email, User: idToken.Subject, + Groups: claims.Groups, }, nil } -// RefreshAccessToken allows the service to refresh an access token without -// prompting the user for permission. -func (p *ProviderData) RefreshAccessToken(refreshToken string) (string, time.Duration, error) { +// Refresh renews a user's session using an access token without reprompting the user. +func (p *IdentityProvider) Refresh(refreshToken string) (*oauth2.Token, error) { if refreshToken == "" { - return "", 0, errors.New("authenticate/providers: missing refresh token") - } - ctx := context.Background() - c := oauth2.Config{ - ClientID: p.ClientID, - ClientSecret: p.ClientSecret, - Endpoint: oauth2.Endpoint{TokenURL: p.ProviderURL}, + return nil, errors.New("authenticate/providers: missing refresh token") } t := oauth2.Token{RefreshToken: refreshToken} - ts := c.TokenSource(ctx, &t) + newToken, err := p.oauth.TokenSource(context.Background(), &t).Token() + if err != nil { + log.Error().Err(err).Msg("authenticate/providers.Refresh") + return nil, err + } log.Info(). Str("RefreshToken", refreshToken). - Msg("authenticate/providers.RefreshAccessToken") + Str("newToken.AccessToken", newToken.AccessToken). + Str("time.Until(newToken.Expiry)", time.Until(newToken.Expiry).String()). + Msg("authenticate/providers.Refresh") - newToken, err := ts.Token() - if err != nil { - log.Error().Err(err).Msg("authenticate/providers.RefreshAccessToken") - return "", 0, err - } - return newToken.AccessToken, time.Until(newToken.Expiry), nil + return newToken, nil } // Revoke enables a user to revoke her token. If the identity provider supports revocation // the endpoint is available, otherwise an error is thrown. -func (p *ProviderData) Revoke(s *sessions.SessionState) error { +func (p *IdentityProvider) Revoke(token string) error { return errors.New("authenticate/providers: revoke not implemented") } - -func sessionRefreshRequired(s *sessions.SessionState) bool { - return s == nil || s.RefreshDeadline.After(time.Now()) || s.RefreshToken == "" -} - -// UserInfo represents the OpenID Connect userinfo claims. -// see: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo -type UserInfo struct { - // Stanard OIDC User fields - Subject string `json:"sub"` - Profile string `json:"profile"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - // custom claims - Name string `json:"name"` // google, gitlab - GivenName string `json:"given_name"` // google - FamilyName string `json:"family_name"` // google - Picture string `json:"picture"` // google,gitlab - Locale string `json:"locale"` // google - Groups []string `json:"groups"` // gitlab - - claims []byte -} - -// Claims unmarshals the raw JSON object claims into the provided object. -func (u *UserInfo) Claims(v interface{}) error { - if u.claims == nil { - return errors.New("authenticate/providers: claims not set") - } - return json.Unmarshal(u.claims, v) -} - -// UserInfo uses the token source to query the provider's user info endpoint. -func (p *ProviderData) UserInfo(ctx context.Context, uri string, tokenSource oauth2.TokenSource) (*UserInfo, error) { - if uri == "" { - return nil, errors.New("authenticate/providers: user info endpoint is not supported by this provider") - } - - req, err := http.NewRequest(http.MethodGet, uri, nil) - if err != nil { - return nil, fmt.Errorf("authenticate/providers: create GET request: %v", err) - } - - token, err := tokenSource.Token() - if err != nil { - return nil, fmt.Errorf("authenticate/providers: get access token: %v", err) - } - token.SetAuthHeader(req) - - resp, err := doRequest(ctx, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s: %s", resp.Status, body) - } - - var userInfo UserInfo - if err := json.Unmarshal(body, &userInfo); err != nil { - return nil, fmt.Errorf("authenticate/providers failed to decode userinfo: %v", err) - } - userInfo.claims = body - return &userInfo, nil -} - -func doRequest(ctx context.Context, req *http.Request) (*http.Response, error) { - client := http.DefaultClient - if c, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok { - client = c - } - return client.Do(req.WithContext(ctx)) -} diff --git a/authenticate/providers/singleflight_middleware.go b/authenticate/providers/singleflight_middleware.go deleted file mode 100644 index 0e8620ee6..000000000 --- a/authenticate/providers/singleflight_middleware.go +++ /dev/null @@ -1,142 +0,0 @@ -package providers // import "github.com/pomerium/pomerium/internal/providers" - -import ( - "errors" - "fmt" - "time" - - "github.com/pomerium/pomerium/internal/sessions" - "github.com/pomerium/pomerium/internal/singleflight" -) - -var ( - _ Provider = &SingleFlightProvider{} -) - -// ErrUnexpectedReturnType is an error for an unexpected return type -var ( - ErrUnexpectedReturnType = errors.New("received unexpected return type from single flight func call") -) - -// SingleFlightProvider middleware provider that multiple requests for the same object -// to be processed as a single request. This is often called request collapsing or coalesce. -// This middleware leverages the golang singlelflight provider, with modifications for metrics. -// -// It's common among HTTP reverse proxy cache servers such as nginx, Squid or Varnish - they all call it something else but works similarly. -// -// * https://www.varnish-cache.org/docs/3.0/tutorial/handling_misbehaving_servers.html -// * http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cache_lock -// * http://wiki.squid-cache.org/Features/CollapsedForwarding -type SingleFlightProvider struct { - provider Provider - - single *singleflight.Group -} - -// NewSingleFlightProvider returns a new SingleFlightProvider -func NewSingleFlightProvider(provider Provider) *SingleFlightProvider { - return &SingleFlightProvider{ - provider: provider, - single: &singleflight.Group{}, - } -} - -func (p *SingleFlightProvider) do(endpoint, key string, fn func() (interface{}, error)) (interface{}, error) { - compositeKey := fmt.Sprintf("%s/%s", endpoint, key) - resp, _, err := p.single.Do(compositeKey, fn) - return resp, err -} - -// Data returns the provider data -func (p *SingleFlightProvider) Data() *ProviderData { - return p.provider.Data() -} - -// Redeem wraps the provider's Redeem function. -func (p *SingleFlightProvider) Redeem(code string) (*sessions.SessionState, error) { - return p.provider.Redeem(code) -} - -// ValidateSessionState wraps the provider's ValidateSessionState in a single flight call. -func (p *SingleFlightProvider) ValidateSessionState(s *sessions.SessionState) bool { - response, err := p.do("ValidateSessionState", s.AccessToken, func() (interface{}, error) { - valid := p.provider.ValidateSessionState(s) - return valid, nil - }) - if err != nil { - return false - } - - valid, ok := response.(bool) - if !ok { - return false - } - - return valid -} - -// GetSignInURL calls the provider's GetSignInURL function. -func (p *SingleFlightProvider) GetSignInURL(finalRedirect string) string { - return p.provider.GetSignInURL(finalRedirect) -} - -// RefreshSessionIfNeeded wraps the provider's RefreshSessionIfNeeded function in a single flight -// call. -func (p *SingleFlightProvider) RefreshSessionIfNeeded(s *sessions.SessionState) (bool, error) { - response, err := p.do("RefreshSessionIfNeeded", s.RefreshToken, func() (interface{}, error) { - return p.provider.RefreshSessionIfNeeded(s) - }) - if err != nil { - return false, err - } - - r, ok := response.(bool) - if !ok { - return false, ErrUnexpectedReturnType - } - - return r, nil -} - -// Revoke wraps the provider's Revoke function in a single flight call. -func (p *SingleFlightProvider) Revoke(s *sessions.SessionState) error { - _, err := p.do("Revoke", s.AccessToken, func() (interface{}, error) { - err := p.provider.Revoke(s) - return nil, err - }) - return err -} - -// RefreshAccessToken wraps the provider's RefreshAccessToken function in a single flight call. -func (p *SingleFlightProvider) RefreshAccessToken(refreshToken string) (string, time.Duration, error) { - type Response struct { - AccessToken string - ExpiresIn time.Duration - } - response, err := p.do("RefreshAccessToken", refreshToken, func() (interface{}, error) { - accessToken, expiresIn, err := p.provider.RefreshAccessToken(refreshToken) - if err != nil { - return nil, err - } - - return &Response{ - AccessToken: accessToken, - ExpiresIn: expiresIn, - }, nil - }) - if err != nil { - return "", 0, err - } - - r, ok := response.(*Response) - if !ok { - return "", 0, ErrUnexpectedReturnType - } - - return r.AccessToken, r.ExpiresIn, nil -} - -// // Stop calls the provider's stop function -// func (p *SingleFlightProvider) Stop() { -// p.provider.Stop() -// } diff --git a/authenticate/providers/test_provider.go b/authenticate/providers/test_provider.go deleted file mode 100644 index b4a7b3860..000000000 --- a/authenticate/providers/test_provider.go +++ /dev/null @@ -1,77 +0,0 @@ -package providers // import "github.com/pomerium/pomerium/internal/providers" - -import ( - "net/url" - "time" - - "github.com/pomerium/pomerium/internal/sessions" -) - -// TestProvider is a test implementation of the Provider interface. -type TestProvider struct { - *ProviderData - - ValidToken bool - ValidGroup bool - SignInURL string - Refresh bool - RefreshFunc func(string) (string, time.Duration, error) - RefreshError error - Session *sessions.SessionState - RedeemError error - RevokeError error - Groups []string - GroupsError error - GroupsCall int -} - -// NewTestProvider creates a new mock test provider. -func NewTestProvider(providerURL *url.URL) *TestProvider { - host := &url.URL{ - Scheme: "http", - Host: providerURL.Host, - Path: "/authorize", - } - return &TestProvider{ - ProviderData: &ProviderData{ - ProviderName: "Test Provider", - ProviderURL: host.String(), - }, - } -} - -// ValidateSessionState returns the mock provider's ValidToken field value. -func (tp *TestProvider) ValidateSessionState(*sessions.SessionState) bool { - return tp.ValidToken -} - -// GetSignInURL returns the mock provider's SignInURL field value. -func (tp *TestProvider) GetSignInURL(finalRedirect string) string { - return tp.SignInURL -} - -// RefreshSessionIfNeeded returns the mock provider's Refresh value, or an error. -func (tp *TestProvider) RefreshSessionIfNeeded(*sessions.SessionState) (bool, error) { - return tp.Refresh, tp.RefreshError -} - -// RefreshAccessToken returns the mock provider's refresh access token information -func (tp *TestProvider) RefreshAccessToken(s string) (string, time.Duration, error) { - return tp.RefreshFunc(s) -} - -// Revoke returns nil -func (tp *TestProvider) Revoke(*sessions.SessionState) error { - return tp.RevokeError -} - -// ValidateGroupMembership returns the mock provider's GroupsError if not nil, or the Groups field value. -func (tp *TestProvider) ValidateGroupMembership(string, []string) ([]string, error) { - return tp.Groups, tp.GroupsError -} - -// Redeem returns the mock provider's Session and RedeemError field value. -func (tp *TestProvider) Redeem(code string) (*sessions.SessionState, error) { - return tp.Session, tp.RedeemError - -} diff --git a/cmd/pomerium/main.go b/cmd/pomerium/main.go index 319cc44ab..cc38abe9a 100644 --- a/cmd/pomerium/main.go +++ b/cmd/pomerium/main.go @@ -6,11 +6,15 @@ import ( "net/http" "os" + "google.golang.org/grpc" + "github.com/pomerium/pomerium/authenticate" "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" "github.com/pomerium/pomerium/proxy" ) @@ -34,6 +38,10 @@ func main() { } log.Info().Str("version", version.FullVersion()).Msg("cmd/pomerium") + grpcAuth := middleware.NewSharedSecretCred(mainOpts.SharedKey) + grpcOpts := []grpc.ServerOption{grpc.UnaryInterceptor(grpcAuth.ValidateRequest)} + grpcServer := grpc.NewServer(grpcOpts...) + var authenticateService *authenticate.Authenticate var authHost string if mainOpts.Services == "all" || mainOpts.Services == "authenticate" { @@ -51,6 +59,8 @@ func main() { log.Fatal().Err(err).Msg("cmd/pomerium: new authenticate") } authHost = authOpts.RedirectURL.Host + pb.RegisterAuthenticatorServer(grpcServer, authenticateService) + } var proxyService *proxy.Proxy @@ -64,6 +74,7 @@ func main() { if err != nil { log.Fatal().Err(err).Msg("cmd/pomerium: new proxy") } + defer proxyService.AuthenticateConn.Close() } topMux := http.NewServeMux() @@ -85,5 +96,7 @@ func main() { CertFile: mainOpts.CertFile, KeyFile: mainOpts.KeyFile, } - log.Fatal().Err(https.ListenAndServeTLS(httpOpts, topMux)).Msg("cmd/pomerium: https serve failure") + + log.Fatal().Err(https.ListenAndServeTLS(httpOpts, topMux, grpcServer)).Msg("cmd/pomerium: https serve failure") + } diff --git a/cmd/pomerium/options.go b/cmd/pomerium/options.go index 29dd8dc69..abcff1d1f 100644 --- a/cmd/pomerium/options.go +++ b/cmd/pomerium/options.go @@ -1,6 +1,7 @@ package main // import "github.com/pomerium/pomerium/cmd/pomerium" import ( + "errors" "fmt" "github.com/pomerium/envconfig" @@ -14,6 +15,11 @@ type Options struct { // Debug enables more verbose logging, and outputs human-readable logs to Stdout. // Set with POMERIUM_DEBUG Debug bool `envconfig:"POMERIUM_DEBUG"` + + // SharedKey is the shared secret authorization key used to mutually authenticate + // requests between services. + SharedKey string `envconfig:"SHARED_SECRET"` + // Services is a list enabled service mode. If none are selected, "all" is used. // Available options are : "all", "authenticate", "proxy". Services string `envconfig:"SERVICES"` @@ -33,7 +39,7 @@ var defaultOptions = &Options{ Services: "all", } -// optionsFromEnvConfig builds the authentication service's configuration +// optionsFromEnvConfig builds the IdentityProvider service's configuration // options from provided environmental variables func optionsFromEnvConfig() (*Options, error) { o := defaultOptions @@ -43,6 +49,9 @@ func optionsFromEnvConfig() (*Options, error) { if !isValidService(o.Services) { return nil, fmt.Errorf("%s is an invalid service type", o.Services) } + if o.SharedKey == "" { + return nil, errors.New("shared-key cannot be empty") + } return o, nil } diff --git a/cmd/pomerium/options_test.go b/cmd/pomerium/options_test.go index a7305950e..b90b09986 100644 --- a/cmd/pomerium/options_test.go +++ b/cmd/pomerium/options_test.go @@ -6,10 +6,9 @@ import ( "testing" ) -func init() { - os.Clearenv() -} func Test_optionsFromEnvConfig(t *testing.T) { + good := defaultOptions + good.SharedKey = "test" tests := []struct { name string want *Options @@ -17,18 +16,25 @@ func Test_optionsFromEnvConfig(t *testing.T) { envValue string wantErr bool }{ - {"good default with no env settings", defaultOptions, "", "", false}, - {"good service", defaultOptions, "SERVICES", "all", false}, + {"good default with no env settings", good, "", "", false}, {"invalid service type", nil, "SERVICES", "invalid", true}, + {"good service", good, "SERVICES", "all", false}, + {"bad debug boolean", nil, "POMERIUM_DEBUG", "yes", true}, + {"missing shared secret", nil, "SHARED_SECRET", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + os.Clearenv() if tt.envKey != "" { os.Setenv(tt.envKey, tt.envValue) - defer os.Unsetenv(tt.envKey) + } + if tt.envKey != "SHARED_SECRET" { + os.Setenv("SHARED_SECRET", "test") } got, err := optionsFromEnvConfig() + os.Unsetenv(tt.envKey) + if (err != nil) != tt.wantErr { t.Errorf("optionsFromEnvConfig() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/docs/docs/examples.md b/docs/docs/examples.md index 2588c4462..0ff041ded 100644 --- a/docs/docs/examples.md +++ b/docs/docs/examples.md @@ -34,18 +34,29 @@ Uses the [latest pomerium build](https://hub.docker.com/r/pomerium/pomerium) fro - Minimal container-based configuration. - 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 +- Runs a single container for all pomerium services - Routes default to on-prem [helloworld], [httpbin] containers. Customize for your identity provider run `docker-compose up -f basic.docker-compose.yml` #### basic.docker-compose.yml -<<< @/docs/docs/examples/basic.docker-compose.yml +<<< @/docs/docs/examples/docker/basic.docker-compose.yml -### Gitlab On-premise +### NGINX micro-services + +- Docker and Docker-Compose based. +- Uses pre-configured built-in nginx load balancer +- Runs separate containers for each service +- Routes default to on-prem [helloworld], [httpbin], and [gitlab] containers. + +Customize for your identity provider run `docker-compose up -f gitlab.docker-compose.yml` + +#### nginx.docker-compose.yml + +<<< @/docs/docs/examples/docker/nginx.docker-compose.yml + +### Gitlab On-Prem - Docker and Docker-Compose based. - Uses pre-configured built-in nginx load balancer @@ -57,7 +68,7 @@ Customize for your identity provider run `docker-compose up -f gitlab.docker-com #### gitlab.docker-compose.yml -<<< @/docs/docs/examples/gitlab.docker-compose.yml +<<< @/docs/docs/examples/docker/gitlab.docker-compose.yml ## Kubernetes diff --git a/docs/docs/examples/basic.docker-compose.yml b/docs/docs/examples/basic.docker-compose.yml deleted file mode 100644 index d997b631c..000000000 --- a/docs/docs/examples/basic.docker-compose.yml +++ /dev/null @@ -1,100 +0,0 @@ -# Example Pomerium configuration. -# -# NOTE! Change IDP_* settings to match your identity provider settings! -# NOTE! Generate new SHARED_SECRET and COOKIE_SECRET keys! -# 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 -version: "3" -services: - # NGINX routes to pomerium's services depending on the request. - nginx-proxy: - image: jwilder/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: - image: pomerium/pomerium:latest # or `build: .` to build from source - environment: - - SERVICES=authenticate - # auth settings - - REDIRECT_URL=https://sso-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=851877082059-bfgkpj09noog7as3gpc3t7r6n9sjbgs6.apps.googleusercontent.com - - IDP_CLIENT_SECRET=P34wwijKRNP3skP5ag5I12kz - - 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= - - # 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= - - # nginx settings - - VIRTUAL_PROTO=https - - VIRTUAL_HOST=sso-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: - image: pomerium/pomerium:latest # or `build: .` to build from source - environment: - - SERVICES=proxy - # proxy settings - - AUTHENTICATE_SERVICE_URL=https://sso-auth.corp.beyondperimeter.com - - ROUTES=https://httpbin.corp.beyondperimeter.com=http://httpbin,https://hello.corp.beyondperimeter.com=http://hello-world/ - # Generate 256 bit random keys e.g. `head -c32 /dev/urandom | base64` - - SHARED_SECRET=aDducXQzK2tPY3R4TmdqTGhaYS80eGYxcTUvWWJDb2M= - - COOKIE_SECRET=V2JBZk0zWGtsL29UcFUvWjVDWWQ2UHExNXJ0b2VhcDI= - # If set, a JWT based signature is appended to each request header `x-pomerium-jwt-assertion` - # - 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= - - # 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 - - # https://httpbin.corp.beyondperimeter.com - httpbin: - image: kennethreitz/httpbin:latest - expose: - - 80 - # Simple hello world - # https://hello.corp.beyondperimeter.com - hello-world: - image: tutum/hello-world:latest - expose: - - 80 diff --git a/docs/docs/examples/docker/basic.docker-compose.yml b/docs/docs/examples/docker/basic.docker-compose.yml new file mode 100644 index 000000000..9e3d2ec42 --- /dev/null +++ b/docs/docs/examples/docker/basic.docker-compose.yml @@ -0,0 +1,54 @@ +# Example Pomerium configuration. +# +# NOTE! Change IDP_* settings to match your identity provider settings! +# NOTE! Generate new SHARED_SECRET and COOKIE_SECRET keys! +# 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 +version: "3" +services: + pomerium-all: + image: pomerium/pomerium:latest # or `build: .` to build from source + environment: + - 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 + - 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 + - ./cert.pem:/pomerium/cert.pem:ro + - ./privkey.pem:/pomerium/privkey.pem:ro + ports: + - 443:443 + + # https://httpbin.corp.beyondperimeter.com + httpbin: + image: kennethreitz/httpbin:latest + expose: + - 80 + + # https://helloworld.corp.beyondperimeter.com + helloworld: + image: gcr.io/google-samples/hello-app:1.0 + expose: + - 8080 diff --git a/docs/docs/examples/gitlab.docker-compose.yml b/docs/docs/examples/docker/gitlab.docker-compose.yml similarity index 72% rename from docs/docs/examples/gitlab.docker-compose.yml rename to docs/docs/examples/docker/gitlab.docker-compose.yml index 4868a71a5..2bc010d11 100644 --- a/docs/docs/examples/gitlab.docker-compose.yml +++ b/docs/docs/examples/docker/gitlab.docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: nginx: - image: jwilder/nginx-proxy:latest + image: pomerium/nginx-proxy:latest ports: - "443:443" volumes: @@ -17,18 +17,17 @@ services: pomerium-authenticate: build: . restart: always - depends_on: - - "gitlab" environment: - POMERIUM_DEBUG=true - SERVICES=authenticate # auth settings - - REDIRECT_URL=https://sso-auth.corp.beyondperimeter.com/oauth2/callback - - IDP_PROVIDER="gitlab" - - IDP_PROVIDER_URL=https://gitlab.corp.beyondperimeter.com - - IDP_CLIENT_ID=022dbbd09402441dc7af1924b679bc5e6f5bf0d7a555e55b38c51e2e4e6cee76 - - IDP_CLIENT_SECRET=fb7598c520c346915ee369eee57688938fe4f31329a308c4669074da562714b2 - - PROXY_ROOT_DOMAIN=beyondperimeter.com + - 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 @@ -36,14 +35,13 @@ services: - SHARED_SECRET=aDducXQzK2tPY3R4TmdqTGhaYS80eGYxcTUvWWJDb2M= - COOKIE_SECRET=V2JBZk0zWGtsL29UcFUvWjVDWWQ2UHExNXJ0b2VhcDI= - VIRTUAL_PROTO=https - - VIRTUAL_HOST=sso-auth.corp.beyondperimeter.com + - 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 @@ -51,12 +49,17 @@ services: - POMERIUM_DEBUG=true - SERVICES=proxy # proxy settings - - AUTHENTICATE_SERVICE_URL=https://sso-auth.corp.beyondperimeter.com - - ROUTES=https://httpbin.corp.beyondperimeter.com=http://httpbin,https://hello.corp.beyondperimeter.com=http://hello-world/ + - 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). + - OVERIDE_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= - - SIGNING_KEY=LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU0zbXBaSVdYQ1g5eUVneFU2czU3Q2J0YlVOREJTQ0VBdFFGNWZVV0hwY1FvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFaFBRditMQUNQVk5tQlRLMHhTVHpicEVQa1JyazFlVXQxQk9hMzJTRWZVUHpOaTRJV2VaLwpLS0lUdDJxMUlxcFYyS01TYlZEeXI5aWp2L1hoOThpeUV3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= # nginx settings - VIRTUAL_PROTO=https - VIRTUAL_HOST=*.corp.beyondperimeter.com diff --git a/docs/docs/examples/docker/nginx.docker-compose.yml b/docs/docs/examples/docker/nginx.docker-compose.yml new file mode 100644 index 000000000..d276d2376 --- /dev/null +++ b/docs/docs/examples/docker/nginx.docker-compose.yml @@ -0,0 +1,83 @@ +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). + - OVERIDE_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 + - ./cert.pem:/pomerium/cert.pem:ro + - ./privkey.pem:/pomerium/privkey.pem:ro + expose: + - 443 + + # https://httpbin.corp.beyondperimeter.com + httpbin: + image: kennethreitz/httpbin:latest + expose: + - 80 + # https://hello.corp.beyondperimeter.com + hello: + image: gcr.io/google-samples/hello-app:1.0 + expose: + - 8080 diff --git a/docs/docs/examples/kubernetes/authenticate.deploy.yml b/docs/docs/examples/kubernetes/authenticate.deploy.yml index 758fe4215..3dab88667 100644 --- a/docs/docs/examples/kubernetes/authenticate.deploy.yml +++ b/docs/docs/examples/kubernetes/authenticate.deploy.yml @@ -16,7 +16,7 @@ spec: app: pomerium-authenticate spec: containers: - - image: pomerium/pomerium:latest + - image: pomerium/pomerium:grpctest name: pomerium-authenticate ports: - containerPort: 443 @@ -26,7 +26,7 @@ spec: - name: SERVICES value: authenticate - name: REDIRECT_URL - value: https://sso-auth.corp.beyondperimeter.com/oauth2/callback + value: https://auth.corp.beyondperimeter.com/oauth2/callback - name: IDP_PROVIDER value: google - name: IDP_PROVIDER_URL @@ -62,12 +62,6 @@ spec: secretKeyRef: name: certificate-key key: certificate-key - - name: VIRTUAL_PROTO - value: https - - name: VIRTUAL_HOST - value: sso-auth.corp.beyondperimeter.com - - name: VIRTUAL_PORT - value: "443" readinessProbe: httpGet: path: /ping diff --git a/docs/docs/examples/kubernetes/ingress.nginx.yml b/docs/docs/examples/kubernetes/ingress.nginx.yml new file mode 100644 index 000000000..a599e2c33 --- /dev/null +++ b/docs/docs/examples/kubernetes/ingress.nginx.yml @@ -0,0 +1,34 @@ +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: pomerium-http + namespace: pomerium + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" + nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" + # nginx.ingress.kubernetes.io/ssl-passthrough: "true" + +spec: + tls: + - secretName: pomerium-tls + hosts: + - "*.corp.beyondperimeter.com" + - "auth.corp.beyondperimeter.com" + rules: + - host: "*.corp.beyondperimeter.com" + http: + paths: + - paths: + backend: + serviceName: pomerium-proxy-service + servicePort: https + + - host: "auth.corp.beyondperimeter.com" + http: + paths: + - paths: + backend: + serviceName: pomerium-authenticate-service + servicePort: https diff --git a/docs/docs/examples/kubernetes/ingress.yml b/docs/docs/examples/kubernetes/ingress.yml index 14e3372d5..5d4a9d711 100644 --- a/docs/docs/examples/kubernetes/ingress.yml +++ b/docs/docs/examples/kubernetes/ingress.yml @@ -12,28 +12,20 @@ spec: - secretName: pomerium-tls hosts: - "*.corp.beyondperimeter.com" - - "sso-auth.corp.beyondperimeter.com" + - "auth.corp.beyondperimeter.com" rules: - host: "*.corp.beyondperimeter.com" http: paths: - - path: / + - paths: backend: serviceName: pomerium-proxy-service - servicePort: 443 - - path: /* - backend: - serviceName: pomerium-proxy-service - servicePort: 443 + servicePort: https - - host: "sso-auth.corp.beyondperimeter.com" + - host: "auth.corp.beyondperimeter.com" http: paths: - - path: /* + - paths: backend: serviceName: pomerium-authenticate-service - servicePort: 443 - - path: / - backend: - serviceName: pomerium-authenticate-service - servicePort: 443 + servicePort: https diff --git a/docs/docs/examples/kubernetes/proxy.deploy.yml b/docs/docs/examples/kubernetes/proxy.deploy.yml index 50eec8b6d..b1af3665c 100644 --- a/docs/docs/examples/kubernetes/proxy.deploy.yml +++ b/docs/docs/examples/kubernetes/proxy.deploy.yml @@ -16,7 +16,7 @@ spec: app: pomerium-proxy spec: containers: - - image: pomerium/pomerium:latest + - image: pomerium/pomerium:grpctest name: pomerium-proxy ports: - containerPort: 443 @@ -24,11 +24,15 @@ spec: protocol: TCP env: - name: ROUTES - value: https://httpbin.corp.beyondperimeter.com=https://httpbin.org + 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: AUTHENTICATE_SERVICE_URL - value: https://sso-auth.corp.beyondperimeter.com + value: https://auth.corp.beyondperimeter.com + - name: AUTHENTICATE_INTERNAL_URL + value: "pomerium-authenticate-service.pomerium.svc.cluster.local:443" + - name: OVERIDE_CERTIFICATE_NAME + value: "*.corp.beyondperimeter.com" - name: SHARED_SECRET valueFrom: secretKeyRef: @@ -54,12 +58,6 @@ spec: secretKeyRef: name: certificate-key key: certificate-key - - name: VIRTUAL_PROTO - value: https - - name: VIRTUAL_HOST - value: "*.corp.beyondperimeter.com" - - name: VIRTUAL_PORT - value: "443" readinessProbe: httpGet: path: /ping diff --git a/docs/docs/identity-providers.md b/docs/docs/identity-providers.md index 24d211f0b..8487d7ded 100644 --- a/docs/docs/identity-providers.md +++ b/docs/docs/identity-providers.md @@ -10,9 +10,9 @@ description: >- This article describes how to configure pomerium to use a third-party identity service for single-sign-on. -There are a few configuration steps required for identity provider integration. Most providers support [OpenID Connect] which provides a standardized interface for authentication. In this guide we'll cover how to do the following for each identity provider: +There are a few configuration steps required for identity provider integration. Most providers support [OpenID Connect] which provides a standardized interface for IdentityProvider. In this guide we'll cover how to do the following for each identity provider: -1. Establish a **Redirect URL** with the identity provider which is called after authentication. +1. Establish a **Redirect URL** with the identity provider which is called after IdentityProvider. 2. Generate a **Client ID** and **Client Secret**. 3. Configure pomerium to use the **Client ID** and **Client Secret** keys. diff --git a/docs/guide/kubernetes.md b/docs/guide/kubernetes.md index c453ace5e..6bdd0fb16 100644 --- a/docs/guide/kubernetes.md +++ b/docs/guide/kubernetes.md @@ -52,7 +52,7 @@ 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). -4. Configure an ingress to do serve TLS between client and load balancer +4. Configure an ingress load balancer. ```bash sh ./scripts/kubernetes_gke.sh diff --git a/docs/guide/readme.md b/docs/guide/readme.md index ff066a84a..cc994b971 100644 --- a/docs/guide/readme.md +++ b/docs/guide/readme.md @@ -20,7 +20,7 @@ Place your domain's wild-card TLS certificate next to the compose file. If you d ## Run -Docker-compose will automatically download the latest pomerium release as well as two example containers and an nginx load balancer all in one step. +Docker-compose will automatically download the latest pomerium release as well as two example containers. ```bash docker-compose up diff --git a/go.mod b/go.mod index d1e503188..9feeea951 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/pomerium/pomerium require ( github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/mock v1.2.0 + github.com/golang/protobuf v1.2.0 github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pomerium/envconfig v1.3.1-0.20190112072701-14cbcf832d31 github.com/pomerium/go-oidc v2.0.0+incompatible @@ -10,10 +12,11 @@ require ( github.com/rs/zerolog v1.11.0 github.com/stretchr/testify v1.2.2 // indirect golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 - golang.org/x/net v0.0.0-20181220203305-927f97764cc3 // indirect + golang.org/x/net v0.0.0-20181220203305-927f97764cc3 golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 // indirect golang.org/x/sys v0.0.0-20190116161447-11f53e031339 // indirect google.golang.org/appengine v1.4.0 // indirect + google.golang.org/grpc v1.18.0 gopkg.in/square/go-jose.v2 v2.2.1 ) diff --git a/go.sum b/go.sum index a6d9af1ce..d537ad4e5 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,18 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 h1:wOysYcIdqv3WnvwqFFzrYCFALPED7qkUGaLXu359GSc= github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3/go.mod h1:UMqtWQTnOe4byzwe7Zhwh8f8s+36uszN51sJrSIZlTE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 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.1-0.20190112072701-14cbcf832d31 h1:bNqUesLWa+RUxQvSaV3//dEFviXdCSvMF9GKDOopFLU= @@ -18,17 +27,30 @@ github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890 h1:uESlIz09WIHT2I+pasSXcpLYqYK8wHcdCetU3VuMBJE= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/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-20190116161447-11f53e031339 h1:g/Jesu8+QLnA0CPzF3E1pURg0Byr7i6jLoX5sqjcAh0= golang.org/x/sys v0.0.0-20190116161447-11f53e031339/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 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 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.18.0 h1:IZl7mfBGfbhYx2p2rKRtYgDFw6SBz+kclmxYrCksPPA= +google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= gopkg.in/square/go-jose.v2 v2.2.1 h1:uRIz/V7RfMsMgGnCp+YybIdstDIz8wc0H283wHQfwic= gopkg.in/square/go-jose.v2 v2.2.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 5e57c789a..f03ba2654 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -3,12 +3,12 @@ version: 0.0.1 apiVersion: v1 appVersion: 0.0.1 home: http://www.pomerium.io/ -description: A reverse proxy that provides authentication with Google, Okta, Azure or other providers +description: A reverse proxy that provides IdentityProvider with Google, Okta, Azure or other providers keywords: - kubernetes - oauth - oauth2 -- authentication +- IdentityProvider - google - okta - azure diff --git a/helm/values.yaml b/helm/values.yaml index 490b49abf..ffd140dd4 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -24,7 +24,7 @@ proxy: # For any other settings that are optional # ADDRESS, POMERIUM_DEBUG, CERTIFICATE_FILE, CERTIFICATE_KEY_FILE -# PROXY_ROOT_DOMAIN, COOKIE_DOMAIN, COOKIE_EXPIRE, COOKIE_REFRESH, COOKIE_SECURE, COOKIE_HTTP_ONLY, IDP_SCOPE +# PROXY_ROOT_DOMAIN, COOKIE_DOMAIN, COOKIE_EXPIRE, COOKIE_REFRESH, COOKIE_SECURE, COOKIE_HTTP_ONLY, IDP_SCOPES # DEFAULT_UPSTREAM_TIMEOUT, PASS_ACCESS_TOKEN, SESSION_VALID_TTL, SESSION_LIFETIME_TTL, GRACE_PERIOD_TTL extraEnv: {} diff --git a/internal/cryptutil/encrypt.go b/internal/cryptutil/encrypt.go index c5e84dde6..2fc28b414 100644 --- a/internal/cryptutil/encrypt.go +++ b/internal/cryptutil/encrypt.go @@ -38,7 +38,7 @@ type XChaCha20Cipher struct { aead cipher.AEAD } -// NewCipher returns a new XChacha20poly1305 cipher. +// NewCipher takes secret key and returns a new XChacha20poly1305 cipher. func NewCipher(secret []byte) (*XChaCha20Cipher, error) { aead, err := chacha20poly1305.NewX(secret) if err != nil { diff --git a/internal/https/https.go b/internal/https/https.go index f31e8d79b..76f096265 100644 --- a/internal/https/https.go +++ b/internal/https/https.go @@ -8,9 +8,11 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/pomerium/pomerium/internal/fileutil" + "google.golang.org/grpc" ) // Options contains the configurations settings for a TLS http server. @@ -55,7 +57,7 @@ func (opt *Options) applyDefaults() { // ListenAndServeTLS serves the provided handlers by HTTPS // using the provided options. -func ListenAndServeTLS(opt *Options, handler http.Handler) error { +func ListenAndServeTLS(opt *Options, httpHandler http.Handler, grpcHandler *grpc.Server) error { if opt == nil { opt = defaultOptions } else { @@ -82,16 +84,21 @@ func ListenAndServeTLS(opt *Options, handler http.Handler) error { ln = tls.NewListener(ln, config) + var h http.Handler + if grpcHandler == nil { + h = httpHandler + } else { + h = grpcHandlerFunc(grpcHandler, httpHandler) + } // Set up the main server. server := &http.Server{ ReadHeaderTimeout: 5 * time.Second, - ReadTimeout: 15 * time.Second, - // WriteTimeout is set to 0 because it also pertains to - // streaming replies, e.g., the DirServer.Watch interface. + ReadTimeout: 10 * time.Second, + // WriteTimeout is set to 0 for streaming replies WriteTimeout: 0, IdleTimeout: 60 * time.Second, TLSConfig: config, - Handler: handler, + Handler: h, } return server.Serve(ln) @@ -130,10 +137,15 @@ func readCertificateFile(certFile, certKeyFile string) (*tls.Certificate, error) } // newDefaultTLSConfig creates a new TLS config based on the certificate files given. -// see also: +// See : // https://wiki.mozilla.org/Security/Server_Side_TLS#Recommended_configurations +// https://blog.cloudflare.com/exposing-go-on-the-internet/ +// https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices +// https://github.com/golang/go/blob/df91b8044dbe790c69c16058330f545be069cc1f/src/crypto/tls/common.go#L919 func newDefaultTLSConfig(cert *tls.Certificate) (*tls.Config, error) { tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + // Prioritize cipher suites sped up by AES-NI (AES-GCM) CipherSuites: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, @@ -142,10 +154,29 @@ func newDefaultTLSConfig(cert *tls.Certificate) (*tls.Config, error) { tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, }, - MinVersion: tls.VersionTLS12, PreferServerCipherSuites: true, - Certificates: []tls.Certificate{*cert}, + // Use curves which have assembly implementations + CurvePreferences: []tls.CurveID{ + tls.CurveP256, + tls.X25519, + }, + Certificates: []tls.Certificate{*cert}, + // HTTP/2 must be enabled manually when using http.Serve + NextProtos: []string{"h2"}, } tlsConfig.BuildNameToCertificate() return tlsConfig, nil } + +// grpcHandlerFunc splits request serving between gRPC and HTTPS depending on the request type. +// Requires HTTP/2. +func grpcHandlerFunc(rpcServer *grpc.Server, other http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + if r.ProtoMajor == 2 && strings.Contains(ct, "application/grpc") { + rpcServer.ServeHTTP(w, r) + } else { + other.ServeHTTP(w, r) + } + }) +} diff --git a/internal/httputil/client.go b/internal/httputil/client.go index 99f7902d8..7f325039a 100644 --- a/internal/httputil/client.go +++ b/internal/httputil/client.go @@ -11,6 +11,8 @@ import ( "net/http" "net/url" "time" + + "github.com/pomerium/pomerium/internal/log" ) // ErrTokenRevoked signifies a token revokation or expiration error @@ -59,6 +61,7 @@ func Client(method, endpoint, userAgent string, params url.Values, response inte if err != nil { return err } + log.Info().Msgf("%s", respBody) if resp.StatusCode != http.StatusOK { switch resp.StatusCode { diff --git a/internal/log/log.go b/internal/log/log.go index 5a41413b1..4bdfb038c 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -10,11 +10,12 @@ import ( ) // Logger is the global logger. -var Logger = zerolog.New(os.Stderr).With().Timestamp().Logger() +var Logger = zerolog.New(os.Stdout).With().Timestamp().Logger() // SetDebugMode tells the logger to use standard out and pretty print output. func SetDebugMode() { Logger = Logger.Output(zerolog.ConsoleWriter{Out: os.Stdout}) + // zerolog.SetGlobalLevel(zerolog.InfoLevel) } // With creates a child logger with the field added to its context. diff --git a/internal/middleware/grpc.go b/internal/middleware/grpc.go new file mode 100644 index 000000000..60324f252 --- /dev/null +++ b/internal/middleware/grpc.go @@ -0,0 +1,48 @@ +package middleware // import "github.com/pomerium/pomerium/internal/middleware" + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// SharedSecretCred is a simple token-based method of mutual authentication. +type SharedSecretCred struct{ sharedSecret string } + +// NewSharedSecretCred returns a new instance of shared secret credential middleware for gRPC clients +func NewSharedSecretCred(secret string) *SharedSecretCred { + return &SharedSecretCred{sharedSecret: secret} +} + +// GetRequestMetadata sets the value for "authorization" key +func (s SharedSecretCred) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { + return map[string]string{"authorization": s.sharedSecret}, nil +} + +// RequireTransportSecurity should be true as we want to have it encrypted over the wire. +func (s SharedSecretCred) RequireTransportSecurity() bool { return false } + +// ValidateRequest ensures a valid token exists within a request's metadata. If +// the token is missing or invalid, the interceptor blocks execution of the +// handler and returns an error. Otherwise, the interceptor invokes the unary +// handler. +func (s SharedSecretCred) ValidateRequest(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "missing metadata") + } + // The keys within metadata.MD are normalized to lowercase. + // See: https://godoc.org/google.golang.org/grpc/metadata#New + elem, ok := md["authorization"] + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "no auth details supplied") + } + if elem[0] != s.sharedSecret { + return nil, status.Errorf(codes.Unauthenticated, "invalid shared secrets") + } + // Continue execution of handler after ensuring a valid token. + return handler(ctx, req) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index cd2ea4cd2..252c2bc8f 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -119,7 +119,7 @@ func ValidateHost(mux map[string]http.Handler) func(next http.Handler) http.Hand return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, ok := mux[r.Host]; !ok { - httputil.ErrorResponse(w, r, "Unknown host to route", http.StatusNotFound) + httputil.ErrorResponse(w, r, "Unknown route", http.StatusNotFound) return } next.ServeHTTP(w, r) diff --git a/internal/sessions/session_state.go b/internal/sessions/session_state.go index 0f3f663fc..7fc8bbbe2 100644 --- a/internal/sessions/session_state.go +++ b/internal/sessions/session_state.go @@ -16,15 +16,16 @@ var ( type SessionState struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` // https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + IDToken string `json:"id_token"` RefreshDeadline time.Time `json:"refresh_deadline"` LifetimeDeadline time.Time `json:"lifetime_deadline"` ValidDeadline time.Time `json:"valid_deadline"` GracePeriodStart time.Time `json:"grace_period_start"` - Email string `json:"email"` - User string `json:"user"` + Email string `json:"email"` + User string `json:"user"` // 'sub' in jwt parlance + Groups []string `json:"groups"` } // LifetimePeriodExpired returns true if the lifetime has expired diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go index a81811b9a..d4fdb1e55 100644 --- a/internal/templates/templates_test.go +++ b/internal/templates/templates_test.go @@ -2,11 +2,12 @@ package templates // import "github.com/pomerium/pomerium/internal/templates" import ( "testing" - - "github.com/pomerium/pomerium/internal/testutil" ) func TestTemplatesCompile(t *testing.T) { templates := New() - testutil.NotEqual(t, templates, nil) + if templates == nil { + t.Errorf("unexpected nil value %#v", templates) + + } } diff --git a/proto/authenticate/authenticate.pb.go b/proto/authenticate/authenticate.pb.go new file mode 100644 index 000000000..8059ee458 --- /dev/null +++ b/proto/authenticate/authenticate.pb.go @@ -0,0 +1,477 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: authenticate.proto + +package authenticate + +import proto "github.com/golang/protobuf/proto" +import fmt "fmt" +import math "math" +import timestamp "github.com/golang/protobuf/ptypes/timestamp" + +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 AuthenticateRequest struct { + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *AuthenticateRequest) Reset() { *m = AuthenticateRequest{} } +func (m *AuthenticateRequest) String() string { return proto.CompactTextString(m) } +func (*AuthenticateRequest) ProtoMessage() {} +func (*AuthenticateRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_authenticate_b52fdd447b0a5778, []int{0} +} +func (m *AuthenticateRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_AuthenticateRequest.Unmarshal(m, b) +} +func (m *AuthenticateRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_AuthenticateRequest.Marshal(b, m, deterministic) +} +func (dst *AuthenticateRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_AuthenticateRequest.Merge(dst, src) +} +func (m *AuthenticateRequest) XXX_Size() int { + return xxx_messageInfo_AuthenticateRequest.Size(m) +} +func (m *AuthenticateRequest) XXX_DiscardUnknown() { + xxx_messageInfo_AuthenticateRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_AuthenticateRequest proto.InternalMessageInfo + +func (m *AuthenticateRequest) GetCode() string { + if m != nil { + return m.Code + } + return "" +} + +type AuthenticateReply struct { + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + RefreshToken string `protobuf:"bytes,2,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + IdToken string `protobuf:"bytes,3,opt,name=id_token,json=idToken,proto3" json:"id_token,omitempty"` + User string `protobuf:"bytes,4,opt,name=user,proto3" json:"user,omitempty"` + Email string `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"` + Expiry *timestamp.Timestamp `protobuf:"bytes,6,opt,name=expiry,proto3" json:"expiry,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *AuthenticateReply) Reset() { *m = AuthenticateReply{} } +func (m *AuthenticateReply) String() string { return proto.CompactTextString(m) } +func (*AuthenticateReply) ProtoMessage() {} +func (*AuthenticateReply) Descriptor() ([]byte, []int) { + return fileDescriptor_authenticate_b52fdd447b0a5778, []int{1} +} +func (m *AuthenticateReply) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_AuthenticateReply.Unmarshal(m, b) +} +func (m *AuthenticateReply) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_AuthenticateReply.Marshal(b, m, deterministic) +} +func (dst *AuthenticateReply) XXX_Merge(src proto.Message) { + xxx_messageInfo_AuthenticateReply.Merge(dst, src) +} +func (m *AuthenticateReply) XXX_Size() int { + return xxx_messageInfo_AuthenticateReply.Size(m) +} +func (m *AuthenticateReply) XXX_DiscardUnknown() { + xxx_messageInfo_AuthenticateReply.DiscardUnknown(m) +} + +var xxx_messageInfo_AuthenticateReply proto.InternalMessageInfo + +func (m *AuthenticateReply) GetAccessToken() string { + if m != nil { + return m.AccessToken + } + return "" +} + +func (m *AuthenticateReply) GetRefreshToken() string { + if m != nil { + return m.RefreshToken + } + return "" +} + +func (m *AuthenticateReply) GetIdToken() string { + if m != nil { + return m.IdToken + } + return "" +} + +func (m *AuthenticateReply) GetUser() string { + if m != nil { + return m.User + } + return "" +} + +func (m *AuthenticateReply) GetEmail() string { + if m != nil { + return m.Email + } + return "" +} + +func (m *AuthenticateReply) GetExpiry() *timestamp.Timestamp { + if m != nil { + return m.Expiry + } + return nil +} + +type ValidateRequest struct { + IdToken string `protobuf:"bytes,1,opt,name=id_token,json=idToken,proto3" json:"id_token,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ValidateRequest) Reset() { *m = ValidateRequest{} } +func (m *ValidateRequest) String() string { return proto.CompactTextString(m) } +func (*ValidateRequest) ProtoMessage() {} +func (*ValidateRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_authenticate_b52fdd447b0a5778, []int{2} +} +func (m *ValidateRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ValidateRequest.Unmarshal(m, b) +} +func (m *ValidateRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ValidateRequest.Marshal(b, m, deterministic) +} +func (dst *ValidateRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_ValidateRequest.Merge(dst, src) +} +func (m *ValidateRequest) XXX_Size() int { + return xxx_messageInfo_ValidateRequest.Size(m) +} +func (m *ValidateRequest) XXX_DiscardUnknown() { + xxx_messageInfo_ValidateRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_ValidateRequest proto.InternalMessageInfo + +func (m *ValidateRequest) GetIdToken() string { + if m != nil { + return m.IdToken + } + return "" +} + +type ValidateReply 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 *ValidateReply) Reset() { *m = ValidateReply{} } +func (m *ValidateReply) String() string { return proto.CompactTextString(m) } +func (*ValidateReply) ProtoMessage() {} +func (*ValidateReply) Descriptor() ([]byte, []int) { + return fileDescriptor_authenticate_b52fdd447b0a5778, []int{3} +} +func (m *ValidateReply) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ValidateReply.Unmarshal(m, b) +} +func (m *ValidateReply) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ValidateReply.Marshal(b, m, deterministic) +} +func (dst *ValidateReply) XXX_Merge(src proto.Message) { + xxx_messageInfo_ValidateReply.Merge(dst, src) +} +func (m *ValidateReply) XXX_Size() int { + return xxx_messageInfo_ValidateReply.Size(m) +} +func (m *ValidateReply) XXX_DiscardUnknown() { + xxx_messageInfo_ValidateReply.DiscardUnknown(m) +} + +var xxx_messageInfo_ValidateReply proto.InternalMessageInfo + +func (m *ValidateReply) GetIsValid() bool { + if m != nil { + return m.IsValid + } + return false +} + +type RefreshRequest struct { + RefreshToken string `protobuf:"bytes,1,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *RefreshRequest) Reset() { *m = RefreshRequest{} } +func (m *RefreshRequest) String() string { return proto.CompactTextString(m) } +func (*RefreshRequest) ProtoMessage() {} +func (*RefreshRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_authenticate_b52fdd447b0a5778, []int{4} +} +func (m *RefreshRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_RefreshRequest.Unmarshal(m, b) +} +func (m *RefreshRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_RefreshRequest.Marshal(b, m, deterministic) +} +func (dst *RefreshRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_RefreshRequest.Merge(dst, src) +} +func (m *RefreshRequest) XXX_Size() int { + return xxx_messageInfo_RefreshRequest.Size(m) +} +func (m *RefreshRequest) XXX_DiscardUnknown() { + xxx_messageInfo_RefreshRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_RefreshRequest proto.InternalMessageInfo + +func (m *RefreshRequest) GetRefreshToken() string { + if m != nil { + return m.RefreshToken + } + return "" +} + +type RefreshReply struct { + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + Expiry *timestamp.Timestamp `protobuf:"bytes,2,opt,name=expiry,proto3" json:"expiry,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *RefreshReply) Reset() { *m = RefreshReply{} } +func (m *RefreshReply) String() string { return proto.CompactTextString(m) } +func (*RefreshReply) ProtoMessage() {} +func (*RefreshReply) Descriptor() ([]byte, []int) { + return fileDescriptor_authenticate_b52fdd447b0a5778, []int{5} +} +func (m *RefreshReply) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_RefreshReply.Unmarshal(m, b) +} +func (m *RefreshReply) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_RefreshReply.Marshal(b, m, deterministic) +} +func (dst *RefreshReply) XXX_Merge(src proto.Message) { + xxx_messageInfo_RefreshReply.Merge(dst, src) +} +func (m *RefreshReply) XXX_Size() int { + return xxx_messageInfo_RefreshReply.Size(m) +} +func (m *RefreshReply) XXX_DiscardUnknown() { + xxx_messageInfo_RefreshReply.DiscardUnknown(m) +} + +var xxx_messageInfo_RefreshReply proto.InternalMessageInfo + +func (m *RefreshReply) GetAccessToken() string { + if m != nil { + return m.AccessToken + } + return "" +} + +func (m *RefreshReply) GetExpiry() *timestamp.Timestamp { + if m != nil { + return m.Expiry + } + return nil +} + +func init() { + proto.RegisterType((*AuthenticateRequest)(nil), "authenticate.AuthenticateRequest") + proto.RegisterType((*AuthenticateReply)(nil), "authenticate.AuthenticateReply") + proto.RegisterType((*ValidateRequest)(nil), "authenticate.ValidateRequest") + proto.RegisterType((*ValidateReply)(nil), "authenticate.ValidateReply") + proto.RegisterType((*RefreshRequest)(nil), "authenticate.RefreshRequest") + proto.RegisterType((*RefreshReply)(nil), "authenticate.RefreshReply") +} + +// 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 + +// AuthenticatorClient is the client API for Authenticator service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type AuthenticatorClient interface { + Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateReply, error) + Validate(ctx context.Context, in *ValidateRequest, opts ...grpc.CallOption) (*ValidateReply, error) + Refresh(ctx context.Context, in *RefreshRequest, opts ...grpc.CallOption) (*RefreshReply, error) +} + +type authenticatorClient struct { + cc *grpc.ClientConn +} + +func NewAuthenticatorClient(cc *grpc.ClientConn) AuthenticatorClient { + return &authenticatorClient{cc} +} + +func (c *authenticatorClient) Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateReply, error) { + out := new(AuthenticateReply) + err := c.cc.Invoke(ctx, "/authenticate.Authenticator/Authenticate", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authenticatorClient) Validate(ctx context.Context, in *ValidateRequest, opts ...grpc.CallOption) (*ValidateReply, error) { + out := new(ValidateReply) + err := c.cc.Invoke(ctx, "/authenticate.Authenticator/Validate", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authenticatorClient) Refresh(ctx context.Context, in *RefreshRequest, opts ...grpc.CallOption) (*RefreshReply, error) { + out := new(RefreshReply) + err := c.cc.Invoke(ctx, "/authenticate.Authenticator/Refresh", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AuthenticatorServer is the server API for Authenticator service. +type AuthenticatorServer interface { + Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateReply, error) + Validate(context.Context, *ValidateRequest) (*ValidateReply, error) + Refresh(context.Context, *RefreshRequest) (*RefreshReply, error) +} + +func RegisterAuthenticatorServer(s *grpc.Server, srv AuthenticatorServer) { + s.RegisterService(&_Authenticator_serviceDesc, srv) +} + +func _Authenticator_Authenticate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AuthenticateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthenticatorServer).Authenticate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/authenticate.Authenticator/Authenticate", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthenticatorServer).Authenticate(ctx, req.(*AuthenticateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Authenticator_Validate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthenticatorServer).Validate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/authenticate.Authenticator/Validate", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthenticatorServer).Validate(ctx, req.(*ValidateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Authenticator_Refresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RefreshRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthenticatorServer).Refresh(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/authenticate.Authenticator/Refresh", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthenticatorServer).Refresh(ctx, req.(*RefreshRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _Authenticator_serviceDesc = grpc.ServiceDesc{ + ServiceName: "authenticate.Authenticator", + HandlerType: (*AuthenticatorServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Authenticate", + Handler: _Authenticator_Authenticate_Handler, + }, + { + MethodName: "Validate", + Handler: _Authenticator_Validate_Handler, + }, + { + MethodName: "Refresh", + Handler: _Authenticator_Refresh_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "authenticate.proto", +} + +func init() { proto.RegisterFile("authenticate.proto", fileDescriptor_authenticate_b52fdd447b0a5778) } + +var fileDescriptor_authenticate_b52fdd447b0a5778 = []byte{ + // 364 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x92, 0x4f, 0x4f, 0xea, 0x40, + 0x14, 0xc5, 0x5f, 0x79, 0xfc, 0x7b, 0x97, 0xf2, 0x5e, 0xde, 0xd5, 0x45, 0xad, 0x1a, 0xa0, 0x6e, + 0xd0, 0x98, 0x92, 0x60, 0xfc, 0x00, 0x2e, 0x4c, 0x5c, 0x37, 0xc4, 0x2d, 0x29, 0xed, 0x05, 0x26, + 0x16, 0xa6, 0x76, 0xa6, 0x46, 0xbe, 0xa7, 0x9f, 0xc5, 0xb5, 0xe9, 0x4c, 0x2b, 0xad, 0x88, 0x61, + 0xd7, 0x9e, 0xf3, 0x9b, 0x9b, 0x7b, 0xce, 0x0c, 0xa0, 0x9f, 0xca, 0x25, 0xad, 0x25, 0x0b, 0x7c, + 0x49, 0x6e, 0x9c, 0x70, 0xc9, 0xd1, 0x2c, 0x6b, 0x76, 0x6f, 0xc1, 0xf9, 0x22, 0xa2, 0x91, 0xf2, + 0x66, 0xe9, 0x7c, 0x24, 0xd9, 0x8a, 0x84, 0xf4, 0x57, 0xb1, 0xc6, 0x9d, 0x4b, 0x38, 0xba, 0x2b, + 0x1d, 0xf0, 0xe8, 0x39, 0x25, 0x21, 0x11, 0xa1, 0x1e, 0xf0, 0x90, 0x2c, 0xa3, 0x6f, 0x0c, 0xff, + 0x78, 0xea, 0xdb, 0x79, 0x33, 0xe0, 0x7f, 0x95, 0x8d, 0xa3, 0x0d, 0x0e, 0xc0, 0xf4, 0x83, 0x80, + 0x84, 0x98, 0x4a, 0xfe, 0x44, 0xeb, 0xfc, 0x44, 0x47, 0x6b, 0x93, 0x4c, 0xc2, 0x0b, 0xe8, 0x26, + 0x34, 0x4f, 0x48, 0x2c, 0x73, 0xa6, 0xa6, 0x18, 0x33, 0x17, 0x35, 0x74, 0x02, 0x6d, 0x16, 0xe6, + 0xfe, 0x6f, 0xe5, 0xb7, 0x58, 0xa8, 0x2d, 0x84, 0x7a, 0x2a, 0x28, 0xb1, 0xea, 0x7a, 0x99, 0xec, + 0x1b, 0x8f, 0xa1, 0x41, 0x2b, 0x9f, 0x45, 0x56, 0x43, 0x89, 0xfa, 0x07, 0xc7, 0xd0, 0xa4, 0xd7, + 0x98, 0x25, 0x1b, 0xab, 0xd9, 0x37, 0x86, 0x9d, 0xb1, 0xed, 0xea, 0xfc, 0x6e, 0x91, 0xdf, 0x9d, + 0x14, 0xf9, 0xbd, 0x9c, 0x74, 0xae, 0xe1, 0xdf, 0xa3, 0x1f, 0xb1, 0xb0, 0x94, 0xbe, 0xbc, 0x8b, + 0x51, 0xd9, 0xc5, 0xb9, 0x82, 0xee, 0x96, 0xce, 0xf2, 0x67, 0xac, 0x98, 0xbe, 0x64, 0x9a, 0x62, + 0xdb, 0x5e, 0x8b, 0x09, 0x85, 0x38, 0xb7, 0xf0, 0xd7, 0xd3, 0x11, 0x8b, 0xc1, 0x3b, 0x4d, 0x18, + 0xbb, 0x4d, 0x38, 0x04, 0xe6, 0xe7, 0xb1, 0x03, 0x1b, 0xde, 0xe6, 0xae, 0x1d, 0x9a, 0x7b, 0xfc, + 0x6e, 0x40, 0xb7, 0x74, 0x9d, 0x3c, 0xc1, 0x09, 0x98, 0xe5, 0xfb, 0xc5, 0x81, 0x5b, 0x79, 0x5f, + 0xdf, 0xbc, 0x13, 0xbb, 0xf7, 0x13, 0x12, 0x47, 0x1b, 0xe7, 0x17, 0x3e, 0x40, 0xbb, 0x68, 0x0c, + 0xcf, 0xab, 0xf8, 0x97, 0xde, 0xed, 0xd3, 0x7d, 0xb6, 0x9e, 0x74, 0x0f, 0xad, 0xbc, 0x18, 0x3c, + 0xab, 0x92, 0xd5, 0x9a, 0x6d, 0x7b, 0x8f, 0xab, 0xc6, 0xcc, 0x9a, 0xaa, 0x94, 0x9b, 0x8f, 0x00, + 0x00, 0x00, 0xff, 0xff, 0x76, 0x32, 0xe7, 0x1e, 0x3e, 0x03, 0x00, 0x00, +} diff --git a/proto/authenticate/authenticate.proto b/proto/authenticate/authenticate.proto new file mode 100644 index 000000000..476f81bc0 --- /dev/null +++ b/proto/authenticate/authenticate.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; +import "google/protobuf/timestamp.proto"; + +package authenticate; + +service Authenticator { + rpc Authenticate(AuthenticateRequest) returns (AuthenticateReply) {} + rpc Validate(ValidateRequest) returns (ValidateReply) {} + rpc Refresh(RefreshRequest) returns (RefreshReply) {} +} + +message AuthenticateRequest { string code = 1; } + +message AuthenticateReply { + string access_token = 1; + string refresh_token = 2; + string id_token = 3; + string user = 4; + string email = 5; + google.protobuf.Timestamp expiry = 6; +} + +message ValidateRequest { string id_token = 1; } + +message ValidateReply { bool is_valid = 1; } + +message RefreshRequest { string refresh_token = 1; } + +message RefreshReply { + string access_token = 1; + google.protobuf.Timestamp expiry = 2; +} \ No newline at end of file diff --git a/proto/authenticate/mock_authenticate/authenticate_mock_test.go b/proto/authenticate/mock_authenticate/authenticate_mock_test.go new file mode 100644 index 000000000..91c79aad5 --- /dev/null +++ b/proto/authenticate/mock_authenticate/authenticate_mock_test.go @@ -0,0 +1,142 @@ +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 timestampe", err) + } + req := &pb.AuthenticateRequest{Code: "unit_test"} + mockAuthenticateClient.EXPECT().Authenticate( + gomock.Any(), + &rpcMsg{msg: req}, + ).Return(&pb.AuthenticateReply{ + AccessToken: "mocked access token", + RefreshToken: "mocked refresh token", + IdToken: "mocked id token", + User: "user1", + Email: "test@email.com", + Expiry: 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 timestampe", err) + } + req := &pb.RefreshRequest{RefreshToken: "unit_test"} + mockRefreshClient.EXPECT().Refresh( + gomock.Any(), + &rpcMsg{msg: req}, + ).Return(&pb.RefreshReply{ + AccessToken: "mocked access token", + Expiry: 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.RefreshRequest{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.Expiry) + if err != nil { + t.Fatalf("%v failed converting timestampe", err) + } + + if respExpire != fixedDate { + t.Errorf("Refresh: bad expiry got:%v want:%v", respExpire, fixedDate) + } + +} diff --git a/proto/authenticate/mock_authenticate/mock_authenticate.go b/proto/authenticate/mock_authenticate/mock_authenticate.go new file mode 100644 index 000000000..f5931d485 --- /dev/null +++ b/proto/authenticate/mock_authenticate/mock_authenticate.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/pomerium/pomerium/proto/authenticate (interfaces: AuthenticatorClient) + +// Package mock_authenticate is a generated GoMock package. +package mock_authenticate + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + authenticate "github.com/pomerium/pomerium/proto/authenticate" + grpc "google.golang.org/grpc" +) + +// MockAuthenticatorClient is a mock of AuthenticatorClient interface +type MockAuthenticatorClient struct { + ctrl *gomock.Controller + recorder *MockAuthenticatorClientMockRecorder +} + +// MockAuthenticatorClientMockRecorder is the mock recorder for MockAuthenticatorClient +type MockAuthenticatorClientMockRecorder struct { + mock *MockAuthenticatorClient +} + +// NewMockAuthenticatorClient creates a new mock instance +func NewMockAuthenticatorClient(ctrl *gomock.Controller) *MockAuthenticatorClient { + mock := &MockAuthenticatorClient{ctrl: ctrl} + mock.recorder = &MockAuthenticatorClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockAuthenticatorClient) EXPECT() *MockAuthenticatorClientMockRecorder { + return m.recorder +} + +// Authenticate mocks base method +func (m *MockAuthenticatorClient) Authenticate(arg0 context.Context, arg1 *authenticate.AuthenticateRequest, arg2 ...grpc.CallOption) (*authenticate.AuthenticateReply, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Authenticate", varargs...) + ret0, _ := ret[0].(*authenticate.AuthenticateReply) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Authenticate indicates an expected call of Authenticate +func (mr *MockAuthenticatorClientMockRecorder) Authenticate(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authenticate", reflect.TypeOf((*MockAuthenticatorClient)(nil).Authenticate), varargs...) +} + +// Refresh mocks base method +func (m *MockAuthenticatorClient) Refresh(arg0 context.Context, arg1 *authenticate.RefreshRequest, arg2 ...grpc.CallOption) (*authenticate.RefreshReply, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Refresh", varargs...) + ret0, _ := ret[0].(*authenticate.RefreshReply) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Refresh indicates an expected call of Refresh +func (mr *MockAuthenticatorClientMockRecorder) Refresh(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockAuthenticatorClient)(nil).Refresh), varargs...) +} + +// Validate mocks base method +func (m *MockAuthenticatorClient) Validate(arg0 context.Context, arg1 *authenticate.ValidateRequest, arg2 ...grpc.CallOption) (*authenticate.ValidateReply, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Validate", varargs...) + ret0, _ := ret[0].(*authenticate.ValidateReply) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Validate indicates an expected call of Validate +func (mr *MockAuthenticatorClientMockRecorder) Validate(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockAuthenticatorClient)(nil).Validate), varargs...) +} diff --git a/proxy/authenticator/authenticator.go b/proxy/authenticator/authenticator.go deleted file mode 100644 index ea9c7bd1d..000000000 --- a/proxy/authenticator/authenticator.go +++ /dev/null @@ -1,308 +0,0 @@ -package authenticator // import "github.com/pomerium/pomerium/proxy/authenticator" - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "io/ioutil" - "net" - "net/http" - "net/url" - "strings" - "time" - - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/sessions" - "github.com/pomerium/pomerium/internal/version" -) - -var defaultHTTPClient = &http.Client{ - Timeout: time.Second * 5, - Transport: &http.Transport{ - Dial: (&net.Dialer{ - Timeout: 2 * time.Second, - }).Dial, - TLSHandshakeTimeout: 2 * time.Second, - }, -} - -// Errors -var ( - ErrMissingRefreshToken = errors.New("missing refresh token") - ErrAuthProviderUnavailable = errors.New("auth provider unavailable") -) - -// AuthenticateClient holds the data associated with the AuthenticateClients -// necessary to implement a AuthenticateClient interface. -type AuthenticateClient struct { - AuthenticateServiceURL *url.URL - - SharedKey string - - SignInURL *url.URL - SignOutURL *url.URL - RedeemURL *url.URL - RefreshURL *url.URL - ProfileURL *url.URL - ValidateURL *url.URL - - SessionValidTTL time.Duration - SessionLifetimeTTL time.Duration - GracePeriodTTL time.Duration -} - -// NewClient instantiates a new AuthenticateClient with provider data -func NewClient(uri *url.URL, sharedKey string, sessionValid, sessionLifetime, gracePeriod time.Duration) *AuthenticateClient { - return &AuthenticateClient{ - AuthenticateServiceURL: uri, - - SharedKey: sharedKey, - - SignInURL: uri.ResolveReference(&url.URL{Path: "/sign_in"}), - SignOutURL: uri.ResolveReference(&url.URL{Path: "/sign_out"}), - RedeemURL: uri.ResolveReference(&url.URL{Path: "/redeem"}), - RefreshURL: uri.ResolveReference(&url.URL{Path: "/refresh"}), - ValidateURL: uri.ResolveReference(&url.URL{Path: "/validate"}), - ProfileURL: uri.ResolveReference(&url.URL{Path: "/profile"}), - - SessionValidTTL: sessionValid, - SessionLifetimeTTL: sessionLifetime, - GracePeriodTTL: gracePeriod, - } - -} - -func (p *AuthenticateClient) newRequest(method, url string, body io.Reader) (*http.Request, error) { - req, err := http.NewRequest(method, url, body) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", version.UserAgent()) - req.Header.Set("Accept", "application/json") - req.Host = p.AuthenticateServiceURL.Host - return req, nil -} - -func isProviderUnavailable(statusCode int) bool { - return statusCode == http.StatusTooManyRequests || statusCode == http.StatusServiceUnavailable -} - -func extendDeadline(ttl time.Duration) time.Time { - return time.Now().Add(ttl).Truncate(time.Second) -} - -func (p *AuthenticateClient) withinGracePeriod(s *sessions.SessionState) bool { - if s.GracePeriodStart.IsZero() { - s.GracePeriodStart = time.Now() - } - return s.GracePeriodStart.Add(p.GracePeriodTTL).After(time.Now()) -} - -// Redeem takes a redirectURL and code and redeems the SessionState -func (p *AuthenticateClient) Redeem(redirectURL, code string) (*sessions.SessionState, error) { - if code == "" { - return nil, errors.New("missing code") - } - - params := url.Values{} - params.Add("shared_secret", p.SharedKey) - params.Add("code", code) - - req, err := p.newRequest("POST", p.RedeemURL.String(), bytes.NewBufferString(params.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := defaultHTTPClient.Do(req) - if err != nil { - return nil, err - } - - body, err := ioutil.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, err - } - - if resp.StatusCode != 200 { - if isProviderUnavailable(resp.StatusCode) { - return nil, ErrAuthProviderUnavailable - } - return nil, fmt.Errorf("got %d from %q %s", resp.StatusCode, p.RedeemURL.String(), body) - } - - var jsonResponse struct { - AccessToken string `json:"access_token"` - IDToken string `json:"id_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` - Email string `json:"email"` - } - err = json.Unmarshal(body, &jsonResponse) - if err != nil { - return nil, err - } - - user := strings.Split(jsonResponse.Email, "@")[0] - return &sessions.SessionState{ - AccessToken: jsonResponse.AccessToken, - RefreshToken: jsonResponse.RefreshToken, - IDToken: jsonResponse.IDToken, - - RefreshDeadline: extendDeadline(time.Duration(jsonResponse.ExpiresIn) * time.Second), - LifetimeDeadline: extendDeadline(p.SessionLifetimeTTL), - ValidDeadline: extendDeadline(p.SessionValidTTL), - - Email: jsonResponse.Email, - User: user, - }, nil -} - -// RefreshSession refreshes the current session -func (p *AuthenticateClient) RefreshSession(s *sessions.SessionState) (bool, error) { - - if s.RefreshToken == "" { - return false, ErrMissingRefreshToken - } - - newToken, duration, err := p.redeemRefreshToken(s.RefreshToken) - if err != nil { - // When we detect that the auth provider is not explicitly denying - // authentication, and is merely unavailable, we refresh and continue - // as normal during the "grace period" - if err == ErrAuthProviderUnavailable && p.withinGracePeriod(s) { - s.RefreshDeadline = extendDeadline(p.SessionValidTTL) - return true, nil - } - return false, err - } - - s.AccessToken = newToken - s.RefreshDeadline = extendDeadline(duration) - s.GracePeriodStart = time.Time{} - log.Info().Str("user", s.Email).Msg("proxy/authenticator.RefreshSession") - return true, nil -} - -func (p *AuthenticateClient) redeemRefreshToken(refreshToken string) (token string, expires time.Duration, err error) { - params := url.Values{} - params.Add("shared_secret", p.SharedKey) - params.Add("refresh_token", refreshToken) - var req *http.Request - req, err = p.newRequest("POST", p.RefreshURL.String(), bytes.NewBufferString(params.Encode())) - if err != nil { - return - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := defaultHTTPClient.Do(req) - if err != nil { - return - } - var body []byte - body, err = ioutil.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return - } - - if resp.StatusCode != http.StatusCreated { - if isProviderUnavailable(resp.StatusCode) { - err = ErrAuthProviderUnavailable - } else { - err = fmt.Errorf("got %d from %q %s", resp.StatusCode, p.RefreshURL.String(), body) - } - return - } - - var data struct { - AccessToken string `json:"access_token"` - ExpiresIn int64 `json:"expires_in"` - } - err = json.Unmarshal(body, &data) - if err != nil { - return - } - token = data.AccessToken - expires = time.Duration(data.ExpiresIn) * time.Second - return -} - -// ValidateSessionState validates the current sessions state -func (p *AuthenticateClient) ValidateSessionState(s *sessions.SessionState) bool { - // we validate the user's access token is valid - params := url.Values{} - params.Add("shared_secret", p.SharedKey) - req, err := p.newRequest("GET", fmt.Sprintf("%s?%s", p.ValidateURL.String(), params.Encode()), nil) - if err != nil { - log.Info().Err(err).Str("user", s.Email).Msg("proxy/authenticator: error validating session state") - return false - } - req.Header.Set("X-Client-Secret", p.SharedKey) - req.Header.Set("X-Access-Token", s.AccessToken) - req.Header.Set("X-Id-Token", s.IDToken) - - resp, err := defaultHTTPClient.Do(req) - if err != nil { - log.Info().Err(err).Str("user", s.Email).Msg("proxy/authenticator: error validating access token") - return false - } - - if resp.StatusCode != http.StatusOK { - // When we detect that the auth provider is not explicitly denying - // authentication, and is merely unavailable, we validate and continue - // as normal during the "grace period" - if isProviderUnavailable(resp.StatusCode) && p.withinGracePeriod(s) { - s.ValidDeadline = extendDeadline(p.SessionValidTTL) - return true - } - log.Info().Str("user", s.Email).Int("status-code", resp.StatusCode).Msg("proxy/authenticator: bad status code") - - return false - } - - s.ValidDeadline = extendDeadline(p.SessionValidTTL) - s.GracePeriodStart = time.Time{} - return true -} - -// signRedirectURL signs the redirect url string, given a timestamp, and returns it -func (p *AuthenticateClient) signRedirectURL(rawRedirect string, timestamp time.Time) string { - h := hmac.New(sha256.New, []byte(p.SharedKey)) - h.Write([]byte(rawRedirect)) - h.Write([]byte(fmt.Sprint(timestamp.Unix()))) - return base64.URLEncoding.EncodeToString(h.Sum(nil)) -} - -// GetSignInURL with typical oauth parameters -func (p *AuthenticateClient) GetSignInURL(redirectURL *url.URL, state string) *url.URL { - a := *p.SignInURL - now := time.Now() - rawRedirect := redirectURL.String() - params, _ := url.ParseQuery(a.RawQuery) - params.Set("redirect_uri", rawRedirect) - params.Set("shared_secret", p.SharedKey) - params.Set("response_type", "code") - params.Add("state", state) - params.Set("ts", fmt.Sprint(now.Unix())) - params.Set("sig", p.signRedirectURL(rawRedirect, now)) - a.RawQuery = params.Encode() - return &a -} - -// GetSignOutURL creates and returns the sign out URL, given a redirectURL -func (p *AuthenticateClient) GetSignOutURL(redirectURL *url.URL) *url.URL { - a := *p.SignOutURL - now := time.Now() - rawRedirect := redirectURL.String() - params, _ := url.ParseQuery(a.RawQuery) - params.Add("redirect_uri", rawRedirect) - params.Set("ts", fmt.Sprint(now.Unix())) - params.Set("sig", p.signRedirectURL(rawRedirect, now)) - a.RawQuery = params.Encode() - return &a -} diff --git a/proxy/grpc.go b/proxy/grpc.go new file mode 100644 index 000000000..ca2f9a7e3 --- /dev/null +++ b/proxy/grpc.go @@ -0,0 +1,80 @@ +package proxy // import "github.com/pomerium/pomerium/proxy" +import ( + "context" + "errors" + "time" + + "github.com/golang/protobuf/ptypes" + + "github.com/pomerium/pomerium/internal/sessions" + pb "github.com/pomerium/pomerium/proto/authenticate" +) + +// AuthenticateRedeem makes an RPC call to the authenticate service to creates a session state +// from an encrypted code provided as a result of an oauth2 callback process. +func (p *Proxy) AuthenticateRedeem(code string) (*sessions.SessionState, error) { + if code == "" { + return nil, errors.New("missing code") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + r, err := p.AuthenticatorClient.Authenticate(ctx, &pb.AuthenticateRequest{Code: code}) + if err != nil { + return nil, err + } + expiry, err := ptypes.Timestamp(r.Expiry) + if err != nil { + return nil, err + } + return &sessions.SessionState{ + AccessToken: r.AccessToken, + RefreshToken: r.RefreshToken, + IDToken: r.IdToken, + User: r.User, + Email: r.Email, + RefreshDeadline: (expiry).Truncate(time.Second), + LifetimeDeadline: extendDeadline(p.CookieLifetimeTTL), + ValidDeadline: extendDeadline(p.CookieExpire), + }, nil +} + +// AuthenticateRefresh makes an RPC call to the authenticate service to attempt to refresh the +// user's session. Requires a valid refresh token. Will return an error if the identity provider +// has revoked the session or if the refresh token is no longer valid in this context. +func (p *Proxy) AuthenticateRefresh(refreshToken string) (string, time.Time, error) { + if refreshToken == "" { + return "", time.Time{}, errors.New("missing refresh token") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + r, err := p.AuthenticatorClient.Refresh(ctx, &pb.RefreshRequest{RefreshToken: refreshToken}) + if err != nil { + return "", time.Time{}, err + } + + expiry, err := ptypes.Timestamp(r.Expiry) + if err != nil { + return "", time.Time{}, err + } + return r.AccessToken, expiry, nil +} + +// AuthenticateValidate makes an RPC call to the authenticate service to validate the JWT id token; +// does NOT do nonce or revokation validation. +// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation +func (p *Proxy) AuthenticateValidate(idToken string) (bool, error) { + if idToken == "" { + return false, errors.New("missing id token") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + r, err := p.AuthenticatorClient.Validate(ctx, &pb.ValidateRequest{IdToken: idToken}) + if err != nil { + return false, err + } + return r.IsValid, nil +} + +func extendDeadline(ttl time.Duration) time.Time { + return time.Now().Add(ttl).Truncate(time.Second) +} diff --git a/proxy/grpc_test.go b/proxy/grpc_test.go new file mode 100644 index 000000000..3fc5b40b5 --- /dev/null +++ b/proxy/grpc_test.go @@ -0,0 +1,204 @@ +package proxy + +import ( + "fmt" + "reflect" + "testing" + "time" + + "github.com/pomerium/pomerium/internal/sessions" + + "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 TestProxy_AuthenticateRedeem(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockAuthenticateClient := mock.NewMockAuthenticatorClient(ctrl) + req := &pb.AuthenticateRequest{Code: "unit_test"} + mockExpire, err := ptypes.TimestampProto(fixedDate) + if err != nil { + t.Fatalf("%v failed converting timestamp", err) + } + + mockAuthenticateClient.EXPECT().Authenticate( + gomock.Any(), + &rpcMsg{msg: req}, + ).Return(&pb.AuthenticateReply{ + AccessToken: "mocked access token", + RefreshToken: "mocked refresh token", + IdToken: "mocked id token", + User: "user1", + Email: "test@email.com", + Expiry: mockExpire, + }, nil) + p := &Proxy{AuthenticatorClient: mockAuthenticateClient} + tests := []struct { + name string + idToken string + want *sessions.SessionState + wantErr bool + }{ + {"good", "unit_test", &sessions.SessionState{ + AccessToken: "mocked access token", + RefreshToken: "mocked refresh token", + IDToken: "mocked id token", + User: "user1", + Email: "test@email.com", + RefreshDeadline: (fixedDate).Truncate(time.Second), + LifetimeDeadline: extendDeadline(p.CookieLifetimeTTL), + ValidDeadline: extendDeadline(p.CookieExpire), + }, false}, + {"empty code", "", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + got, err := p.AuthenticateRedeem(tt.idToken) + if (err != nil) != tt.wantErr { + t.Errorf("Proxy.AuthenticateValidate() error = %v,\n wantErr %v", err, tt.wantErr) + return + } + if got != nil { + if got.AccessToken != "mocked access token" { + t.Errorf("authenticate: invalid access token") + } + if got.RefreshToken != "mocked refresh token" { + t.Errorf("authenticate: invalid refresh token") + } + if got.IDToken != "mocked id token" { + t.Errorf("authenticate: invalid id token") + } + if got.User != "user1" { + t.Errorf("authenticate: invalid user") + } + if got.Email != "test@email.com" { + t.Errorf("authenticate: invalid email") + } + } + }) + } +} +func TestProxy_AuthenticateValidate(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) + + p := &Proxy{AuthenticatorClient: mockAuthenticateClient} + tests := []struct { + name string + idToken string + want bool + wantErr bool + }{ + {"good", "unit_test", false, false}, + {"empty id token", "", false, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + got, err := p.AuthenticateValidate(tt.idToken) + if (err != nil) != tt.wantErr { + t.Errorf("Proxy.AuthenticateValidate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Proxy.AuthenticateValidate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProxy_AuthenticateRefresh(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRefreshClient := mock.NewMockAuthenticatorClient(ctrl) + req := &pb.RefreshRequest{RefreshToken: "unit_test"} + mockExpire, err := ptypes.TimestampProto(fixedDate) + if err != nil { + t.Fatalf("%v failed converting timestamp", err) + } + mockRefreshClient.EXPECT().Refresh( + gomock.Any(), + &rpcMsg{msg: req}, + ).Return(&pb.RefreshReply{ + AccessToken: "mocked access token", + Expiry: mockExpire, + }, nil).AnyTimes() + + tests := []struct { + name string + refreshToken string + wantAT string + wantExp time.Time + wantErr bool + }{ + {"good", "unit_test", "mocked access token", fixedDate, false}, + {"missing refresh", "", "", time.Time{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Proxy{AuthenticatorClient: mockRefreshClient} + + got, gotExp, err := p.AuthenticateRefresh(tt.refreshToken) + if (err != nil) != tt.wantErr { + t.Errorf("Proxy.AuthenticateRefresh() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.wantAT { + t.Errorf("Proxy.AuthenticateRefresh() got = %v, want %v", got, tt.wantAT) + } + if !reflect.DeepEqual(gotExp, tt.wantExp) { + t.Errorf("Proxy.AuthenticateRefresh() gotExp = %v, want %v", gotExp, tt.wantExp) + } + }) + } +} + +func Test_extendDeadline(t *testing.T) { + + tests := []struct { + name string + ttl time.Duration + want time.Time + }{ + {"good", time.Second, time.Now().Add(time.Second).Truncate(time.Second)}, + {"test nanoseconds truncated", 500 * time.Nanosecond, time.Now().Truncate(time.Second)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extendDeadline(tt.ttl); !reflect.DeepEqual(got, tt.want) { + t.Errorf("extendDeadline() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/proxy/handlers.go b/proxy/handlers.go index 0808c7406..a7e6b2c4e 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -1,6 +1,9 @@ package proxy // import "github.com/pomerium/pomerium/proxy" import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "errors" "fmt" "net/http" @@ -17,7 +20,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") ) @@ -27,6 +30,12 @@ var securityHeaders = map[string]string{ "X-XSS-Protection": "1; mode=block", } +// StateParameter holds the redirect id along with the session id. +type StateParameter struct { + SessionID string `json:"session_id"` + RedirectURI string `json:"redirect_uri"` +} + // Handler returns a http handler for an Proxy func (p *Proxy) Handler() http.Handler { // routes @@ -61,6 +70,8 @@ func (p *Proxy) Handler() http.Handler { c = c.Append(middleware.RefererHandler("referer")) c = c.Append(middleware.RequestIDHandler("req_id", "Request-Id")) c = c.Append(middleware.ValidateHost(p.mux)) + + // serve the middleware and mux h := c.Then(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.ServeHTTP(w, r) })) @@ -74,7 +85,7 @@ func (p *Proxy) RobotsTxt(w http.ResponseWriter, _ *http.Request) { } // Favicon will proxy the request as usual if the user is already authenticated but responds -// with a 404 otherwise, to avoid spurious and confusing authentication attempts when a browser +// with a 404 otherwise, to avoid spurious and confusing IdentityProvider attempts when a browser // automatically requests the favicon on an error page. func (p *Proxy) Favicon(w http.ResponseWriter, r *http.Request) { err := p.Authenticate(w, r) @@ -93,15 +104,13 @@ func (p *Proxy) SignOut(w http.ResponseWriter, r *http.Request) { Host: r.Host, Path: "/", } - fullURL := p.authenticateClient.GetSignOutURL(redirectURL) + fullURL := p.GetSignOutURL(p.AuthenticateURL, redirectURL) http.Redirect(w, r, fullURL.String(), http.StatusFound) } -// OAuthStart begins the authentication flow, encrypting the redirect url +// OAuthStart begins the IdentityProvider flow, encrypting the redirect url // in a request to the provider's sign in endpoint. func (p *Proxy) OAuthStart(w http.ResponseWriter, r *http.Request) { - // The proxy redirects to the authenticator, and provides it with redirectURI (which points - // back to the sso proxy). requestURI := r.URL.String() callbackURL := p.GetRedirectURL(r.Host) @@ -129,19 +138,20 @@ func (p *Proxy) OAuthStart(w http.ResponseWriter, r *http.Request) { httputil.ErrorResponse(w, r, err.Error(), http.StatusInternalServerError) return } - - signinURL := p.authenticateClient.GetSignInURL(callbackURL, encryptedState) - log.FromRequest(r).Info().Msg("redirecting to begin auth flow") + signinURL := p.GetSignInURL(p.AuthenticateURL, callbackURL, encryptedState) + log.FromRequest(r).Info(). + Str("SigninURL", signinURL.String()). + Msg("redirecting to begin auth flow") + // redirect the user to the IdentityProvider provider along with the encrypted state which + // contains a redirect uri pointing back to the proxy http.Redirect(w, r, signinURL.String(), http.StatusFound) } -// OAuthCallback validates the cookie sent back from the provider, then validates he user -// information, and if authorized, redirects the user back to the original application. +// OAuthCallback validates the cookie sent back from the authenticate service. This function will +// contain an error, or it will contain a `code`; the code can be used to fetch an access token, and +// other metadata, from the authenticator. +// finish the oauth cycle func (p *Proxy) OAuthCallback(w http.ResponseWriter, r *http.Request) { - // We receive the callback from the SSO Authenticator. This request will either contain an - // error, or it will contain a `code`; the code can be used to fetch an access token, and - // other metadata, from the authenticator. - // finish the oauth cycle err := r.ParseForm() if err != nil { log.FromRequest(r).Error().Err(err).Msg("failed parsing request form") @@ -153,9 +163,8 @@ func (p *Proxy) OAuthCallback(w http.ResponseWriter, r *http.Request) { httputil.ErrorResponse(w, r, errorString, http.StatusForbidden) return } - // We begin the process of redeeming the code for an access token. - session, err := p.redeemCode(r.Host, r.Form.Get("code")) + session, err := p.AuthenticateRedeem(r.Form.Get("code")) if err != nil { log.FromRequest(r).Error().Err(err).Msg("error redeeming authorization code") httputil.ErrorResponse(w, r, "Internal error", http.StatusInternalServerError) @@ -208,6 +217,14 @@ func (p *Proxy) OAuthCallback(w http.ResponseWriter, r *http.Request) { return } + log.FromRequest(r).Info(). + Str("code", r.Form.Get("code")). + Str("state", r.Form.Get("state")). + Str("RefreshToken", session.RefreshToken). + Str("session", session.AccessToken). + Str("RedirectURI", stateParameter.RedirectURI). + Msg("session") + // This is the redirect back to the original requested application http.Redirect(w, r, stateParameter.RedirectURI, http.StatusFound) } @@ -222,16 +239,14 @@ func (p *Proxy) AuthenticateOnly(w http.ResponseWriter, r *http.Request) { } // Proxy authenticates a request, either proxying the request if it is authenticated, -// or starting the authentication process if not. +// or starting the IdentityProvider service for validation if not. func (p *Proxy) Proxy(w http.ResponseWriter, r *http.Request) { - // Attempts to validate the user and their cookie. err := p.Authenticate(w, r) - // If the authentication is not successful we proceed to start the OAuth Flow with + // If the IdentityProvider is not successful we proceed to start the OAuth Flow with // OAuthStart. If successful, we proceed to proxy to the configured upstream. if err != nil { switch err { case ErrUserNotAuthorized: - //todo(bdd) : custom forbidden page with details and troubleshooting info log.FromRequest(r).Debug().Err(err).Msg("proxy: user access forbidden") httputil.ErrorResponse(w, r, "You don't have access", http.StatusForbidden) return @@ -245,6 +260,9 @@ func (p *Proxy) Proxy(w http.ResponseWriter, r *http.Request) { return } } + // ! ! ! + // todo(bdd): ! Authorization checks will go here ! + // ! ! ! // We have validated the users request and now proxy their request to the provided upstream. route, ok := p.router(r) @@ -270,29 +288,38 @@ func (p *Proxy) Authenticate(w http.ResponseWriter, r *http.Request) (err error) if err != nil { return err } + if session.LifetimePeriodExpired() { + log.FromRequest(r).Info().Msg("proxy.Authenticate: lifetime expired, restarting") return sessions.ErrLifetimeExpired - } else if session.RefreshPeriodExpired() { - ok, err := p.authenticateClient.RefreshSession(session) + } + if session.RefreshPeriodExpired() { + // AccessToken's usually expire after 60 or so minutes. If offline_access scope is set, a + // refresh token (which doesn't change) can be used to request a new access-token. If access + // is revoked by identity provider, or no refresh token is set request will return an error + accessToken, expiry, err := p.AuthenticateRefresh(session.RefreshToken) if err != nil { + log.FromRequest(r).Warn(). + Str("RefreshToken", session.RefreshToken). + Str("AccessToken", session.AccessToken). + Msg("proxy.Authenticate: refresh failure") return err } - if !ok { - return ErrUserNotAuthorized - } - } else if session.ValidationPeriodExpired() { - ok := p.authenticateClient.ValidateSessionState(session) - if !ok { - return ErrUserNotAuthorized - } + session.AccessToken = accessToken + session.RefreshDeadline = expiry + log.FromRequest(r).Info(). + Str("RefreshToken", session.RefreshToken). + Str("AccessToken", session.AccessToken). + Msg("proxy.Authenticate: refresh success") } + err = p.sessionStore.SaveSession(w, r, session) if err != nil { return err } + // pass user & user-email details to client applications r.Header.Set(HeaderUserID, session.User) r.Header.Set(HeaderEmail, session.Email) - // This user has been OK'd. Allow the request! return nil } @@ -316,7 +343,6 @@ func (p *Proxy) router(r *http.Request) (http.Handler, bool) { // GetRedirectURL returns the redirect url for a given Proxy, // setting the scheme to be https if CookieSecure is true. func (p *Proxy) GetRedirectURL(host string) *url.URL { - // TODO: Ensure that we only allow valid upstream hosts in redirect URIs u := p.redirectURL // Build redirect URI from request host if u.Scheme == "" { @@ -326,19 +352,39 @@ func (p *Proxy) GetRedirectURL(host string) *url.URL { return u } -func (p *Proxy) redeemCode(host, code string) (*sessions.SessionState, error) { - if code == "" { - return nil, errors.New("missing code") - } - redirectURL := p.GetRedirectURL(host) - s, err := p.authenticateClient.Redeem(redirectURL.String(), code) - if err != nil { - return s, err - } - - if s.Email == "" { - return s, errors.New("invalid email address") - } - - return s, nil +// signRedirectURL signs the redirect url string, given a timestamp, and returns it +func (p *Proxy) signRedirectURL(rawRedirect string, timestamp time.Time) string { + h := hmac.New(sha256.New, []byte(p.SharedKey)) + h.Write([]byte(rawRedirect)) + h.Write([]byte(fmt.Sprint(timestamp.Unix()))) + return base64.URLEncoding.EncodeToString(h.Sum(nil)) +} + +// GetSignInURL with typical oauth parameters +func (p *Proxy) GetSignInURL(authenticateURL, redirectURL *url.URL, state string) *url.URL { + a := authenticateURL.ResolveReference(&url.URL{Path: "/sign_in"}) + now := time.Now() + rawRedirect := redirectURL.String() + params, _ := url.ParseQuery(a.RawQuery) + params.Set("redirect_uri", rawRedirect) + params.Set("shared_secret", p.SharedKey) + params.Set("response_type", "code") + params.Add("state", state) + params.Set("ts", fmt.Sprint(now.Unix())) + params.Set("sig", p.signRedirectURL(rawRedirect, now)) + a.RawQuery = params.Encode() + return a +} + +// GetSignOutURL creates and returns the sign out URL, given a redirectURL +func (p *Proxy) GetSignOutURL(authenticateURL, redirectURL *url.URL) *url.URL { + a := authenticateURL.ResolveReference(&url.URL{Path: "/sign_out"}) + now := time.Now() + rawRedirect := redirectURL.String() + params, _ := url.ParseQuery(a.RawQuery) + params.Add("redirect_uri", rawRedirect) + params.Set("ts", fmt.Sprint(now.Unix())) + params.Set("sig", p.signRedirectURL(rawRedirect, now)) + a.RawQuery = params.Encode() + return a } diff --git a/proxy/proxy.go b/proxy/proxy.go index e20549132..c29795ad9 100755 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -1,6 +1,8 @@ package proxy // import "github.com/pomerium/pomerium/proxy" import ( + "crypto/tls" + "crypto/x509" "encoding/base64" "errors" "fmt" @@ -13,11 +15,15 @@ import ( "time" "github.com/pomerium/envconfig" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/templates" - "github.com/pomerium/pomerium/proxy/authenticator" + pb "github.com/pomerium/pomerium/proto/authenticate" ) const ( @@ -29,10 +35,13 @@ const ( HeaderEmail = "x-pomerium-authenticated-user-email" ) -// Options represents the configuration options for the proxy service. +// Options represents the configurations available for the proxy service. type Options struct { - // AuthenticateServiceURL specifies the url to the pomerium authenticate http service. - AuthenticateServiceURL *url.URL `envconfig:"AUTHENTICATE_SERVICE_URL"` + // Authenticate service settings + AuthenticateURL *url.URL `envconfig:"AUTHENTICATE_SERVICE_URL"` + AuthenticateInternalURL string `envconfig:"AUTHENTICATE_INTERNAL_URL"` + // + OverideCertificateName string `envconfig:"OVERIDE_CERTIFICATE_NAME"` // SigningKey is a base64 encoded private key used to add a JWT-signature to proxied requests. // See : https://www.pomerium.io/guide/signed-headers.html @@ -40,37 +49,33 @@ type Options struct { // SharedKey is a 32 byte random key used to authenticate access between services. SharedKey string `envconfig:"SHARED_SECRET"` - DefaultUpstreamTimeout time.Duration `envconfig:"DEFAULT_UPSTREAM_TIMEOUT"` + // Session/Cookie management + CookieName string + CookieSecret string `envconfig:"COOKIE_SECRET"` + CookieDomain string `envconfig:"COOKIE_DOMAIN"` + CookieSecure bool `envconfig:"COOKIE_SECURE"` + CookieHTTPOnly bool `envconfig:"COOKIE_HTTP_ONLY"` + CookieExpire time.Duration `envconfig:"COOKIE_EXPIRE"` + CookieRefresh time.Duration `envconfig:"COOKIE_REFRESH"` + CookieLifetimeTTL time.Duration `envconfig:"COOKIE_LIFETIME"` - CookieName string `envconfig:"COOKIE_NAME"` - CookieSecret string `envconfig:"COOKIE_SECRET"` - CookieDomain string `envconfig:"COOKIE_DOMAIN"` - CookieExpire time.Duration `envconfig:"COOKIE_EXPIRE"` - CookieHTTPOnly bool `envconfig:"COOKIE_HTTP_ONLY"` - - PassAccessToken bool `envconfig:"PASS_ACCESS_TOKEN"` - - // session details - SessionValidTTL time.Duration `envconfig:"SESSION_VALID_TTL"` - SessionLifetimeTTL time.Duration `envconfig:"SESSION_LIFETIME_TTL"` - GracePeriodTTL time.Duration `envconfig:"GRACE_PERIOD_TTL"` - - Routes map[string]string `envconfig:"ROUTES"` + // Sub-routes + Routes map[string]string `envconfig:"ROUTES"` + DefaultUpstreamTimeout time.Duration `envconfig:"DEFAULT_UPSTREAM_TIMEOUT"` } // NewOptions returns a new options struct var defaultOptions = &Options{ CookieName: "_pomerium_proxy", - CookieHTTPOnly: false, + CookieHTTPOnly: true, + CookieSecure: true, CookieExpire: time.Duration(168) * time.Hour, + CookieRefresh: time.Duration(30) * time.Minute, + CookieLifetimeTTL: time.Duration(720) * time.Hour, DefaultUpstreamTimeout: time.Duration(10) * time.Second, - SessionLifetimeTTL: time.Duration(720) * time.Hour, - SessionValidTTL: time.Duration(1) * time.Minute, - GracePeriodTTL: time.Duration(3) * time.Hour, - PassAccessToken: false, } -// OptionsFromEnvConfig builds the authentication service's configuration +// OptionsFromEnvConfig builds the IdentityProvider service's configuration // options from provided environmental variables func OptionsFromEnvConfig() (*Options, error) { o := defaultOptions @@ -94,11 +99,11 @@ func (o *Options) Validate() error { return fmt.Errorf("could not parse destination %s as url : %q", to, err) } } - if o.AuthenticateServiceURL == nil { - return errors.New("missing setting: provider-url") + if o.AuthenticateURL == nil { + return errors.New("missing setting: authenticate-service-url") } - if o.AuthenticateServiceURL.Scheme != "https" { - return errors.New("provider-url must be a valid https url") + if o.AuthenticateURL.Scheme != "https" { + return errors.New("authenticate-service-url must be a valid https url") } if o.CookieSecret == "" { return errors.New("missing setting: cookie-secret") @@ -124,26 +129,29 @@ func (o *Options) Validate() error { // Proxy stores all the information associated with proxying a request. type Proxy struct { - PassAccessToken bool + SharedKey string - // services - authenticateClient *authenticator.AuthenticateClient + // Authenticate Service Configuration + AuthenticateURL *url.URL + AuthenticateInternalURL string + AuthenticatorClient pb.AuthenticatorClient + // AuthenticateConn must be closed by Proxy's caller + AuthenticateConn *grpc.ClientConn + + OverideCertificateName string // session - cipher cryptutil.Cipher - csrfStore sessions.CSRFStore - sessionStore sessions.SessionStore + cipher cryptutil.Cipher + csrfStore sessions.CSRFStore + sessionStore sessions.SessionStore + CookieExpire time.Duration + CookieRefresh time.Duration + CookieLifetimeTTL time.Duration redirectURL *url.URL templates *template.Template mux map[string]http.Handler } -// StateParameter holds the redirect id along with the session id. -type StateParameter struct { - SessionID string `json:"session_id"` - RedirectURI string `json:"redirect_uri"` -} - // New takes a Proxy service from options and a validation function. // Function returns an error if options fail to validate. func New(opts *Options) (*Proxy, error) { @@ -173,27 +181,21 @@ func New(opts *Options) (*Proxy, error) { return nil, err } - authClient := authenticator.NewClient( - opts.AuthenticateServiceURL, - opts.SharedKey, - // todo(bdd): fields below should be passed as function args - opts.SessionValidTTL, - opts.SessionLifetimeTTL, - opts.GracePeriodTTL, - ) - p := &Proxy{ // these fields make up the routing mechanism mux: make(map[string]http.Handler), // session state - cipher: cipher, - csrfStore: cookieStore, - sessionStore: cookieStore, - - authenticateClient: authClient, - redirectURL: &url.URL{Path: "/.pomerium/callback"}, - templates: templates.New(), - PassAccessToken: opts.PassAccessToken, + cipher: cipher, + csrfStore: cookieStore, + sessionStore: cookieStore, + AuthenticateURL: opts.AuthenticateURL, + AuthenticateInternalURL: opts.AuthenticateInternalURL, + OverideCertificateName: opts.OverideCertificateName, + SharedKey: opts.SharedKey, + redirectURL: &url.URL{Path: "/.pomerium/callback"}, + templates: templates.New(), + CookieExpire: opts.CookieExpire, + CookieLifetimeTTL: opts.CookieLifetimeTTL, } for from, to := range opts.Routes { @@ -205,8 +207,42 @@ func New(opts *Options) (*Proxy, error) { return nil, err } p.Handle(fromURL.Host, handler) - log.Info().Str("from", fromURL.Host).Str("to", toURL.String()).Msg("proxy.New : route") + log.Info().Str("from", fromURL.Host).Str("to", toURL.String()).Msg("proxy.New: new route") } + // if no port given, assume https/443 + port := p.AuthenticateURL.Port() + if port == "" { + port = "443" + } + authEndpoint := fmt.Sprintf("%s:%s", p.AuthenticateURL.Host, port) + + cp, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + + if p.AuthenticateInternalURL != "" { + authEndpoint = p.AuthenticateInternalURL + } + + log.Info().Str("authEndpoint", authEndpoint).Msgf("proxy.New: grpc authenticate connection") + cert := credentials.NewTLS(&tls.Config{RootCAs: cp}) + if p.OverideCertificateName != "" { + err = cert.OverrideServerName(p.OverideCertificateName) + if err != nil { + return nil, err + } + } + grpcAuth := middleware.NewSharedSecretCred(p.SharedKey) + p.AuthenticateConn, err = grpc.Dial( + authEndpoint, + grpc.WithTransportCredentials(cert), + grpc.WithPerRPCCredentials(grpcAuth), + ) + if err != nil { + return nil, err + } + p.AuthenticatorClient = pb.NewAuthenticatorClient(p.AuthenticateConn) return p, nil } @@ -260,8 +296,8 @@ func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { u.handler.ServeHTTP(w, r) } -// NewReverseProxy creates a reverse proxy to a specified url. -// It adds an X-Forwarded-Host header that is the request's host. +// NewReverseProxy returns a new ReverseProxy that routes URLs to the scheme, host, and +// base path provided in target. NewReverseProxy rewrites the Host header. func NewReverseProxy(to *url.URL) *httputil.ReverseProxy { proxy := httputil.NewSingleHostReverseProxy(to) proxy.Transport = defaultUpstreamTransport diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index 148985dc5..0e049eae5 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -11,10 +11,9 @@ import ( "testing" ) -func init() { - os.Clearenv() -} func TestOptionsFromEnvConfig(t *testing.T) { + os.Clearenv() + tests := []struct { name string want *Options @@ -23,9 +22,9 @@ func TestOptionsFromEnvConfig(t *testing.T) { wantErr bool }{ {"good default, no env settings", defaultOptions, "", "", false}, - {"bad url", nil, "AUTHENTICATE_SERVICE_URL", "%.rjlw", true}, - {"good duration", defaultOptions, "SESSION_VALID_TTL", "1m", false}, - {"bad duration", nil, "SESSION_VALID_TTL", "1sm", true}, + {"bad url", nil, "AUTHENTICATE_SERVICE_URL", "%.ugly", true}, + {"good duration", defaultOptions, "COOKIE_REFRESH", "1m", false}, + {"bad duration", nil, "COOKIE_REFRESH", "1sm", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -46,6 +45,8 @@ func TestOptionsFromEnvConfig(t *testing.T) { } func Test_urlParse(t *testing.T) { + os.Clearenv() + tests := []struct { name string uri string @@ -131,10 +132,10 @@ func TestNewReverseProxyHandler(t *testing.T) { func testOptions() *Options { authurl, _ := url.Parse("https://sso-auth.corp.beyondperimeter.com") return &Options{ - Routes: map[string]string{"corp.example.com": "example.com"}, - AuthenticateServiceURL: authurl, - SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=", - CookieSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=", + Routes: map[string]string{"corp.example.com": "example.com"}, + AuthenticateURL: authurl, + SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=", + CookieSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=", } } @@ -145,10 +146,10 @@ func TestOptions_Validate(t *testing.T) { badToRoute := testOptions() badToRoute.Routes = map[string]string{"^": "example.com"} badAuthURL := testOptions() - badAuthURL.AuthenticateServiceURL = nil + badAuthURL.AuthenticateURL = nil authurl, _ := url.Parse("http://sso-auth.corp.beyondperimeter.com") httpAuthURL := testOptions() - httpAuthURL.AuthenticateServiceURL = authurl + httpAuthURL.AuthenticateURL = authurl emptyCookieSecret := testOptions() emptyCookieSecret.CookieSecret = "" invalidCookieSecret := testOptions() diff --git a/scripts/kubernetes_gke.sh b/scripts/kubernetes_gke.sh index c7d4a431d..bb54bf31d 100755 --- a/scripts/kubernetes_gke.sh +++ b/scripts/kubernetes_gke.sh @@ -3,12 +3,33 @@ # resources to avoid being billed. For reference, this tutorial cost me <10 cents for a couple of hours. # create a cluster -gcloud container clusters create pomerium +gcloud container clusters create pomerium --num-nodes 1 # get cluster credentials os we can use kubctl locally gcloud container clusters get-credentials pomerium # create `pomerium` namespace kubectl create ns pomerium +###################################################################### +#### UNCOMMENT to use helm to install cert-manager & nginx-ingress#### +###################################################################### +# setup service account for tiller used by helm +# kubectl create serviceaccount --namespace kube-system tiller +# kubectl create clusterrolebinding tiller-cluster-rule --clusterrole=cluster-admin --serviceaccount=kube-system:tiller +# helm init --service-account tiller +# # update helm +# helm repo update +# kubectl get deployments -n kube-system +# # create nginx-ingress +# helm install --name nginx-ingress stable/nginx-ingress --set rbac.create=true +# # install cert-manager to auto grab lets encrypt certificates +# kubectl apply -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.6/deploy/manifests/00-crds.yaml +# helm repo update +# helm install --name cert-manager --namespace cert-manager stable/cert-manager +# configure Let’s Encrypt Issuer +# kubectl apply -f docs/docs/examples/kubernetes/issuer.le.prod.yml +# kubectl apply -f docs/docs/examples/kubernetes/issuer.le.stage.yml +# kubectl get certificate + # create our cryptographically random keys kubectl create secret generic -n pomerium shared-secret --from-literal=shared-secret=$(head -c32 /dev/urandom | base64) kubectl create secret generic -n pomerium cookie-secret --from-literal=cookie-secret=$(head -c32 /dev/urandom | base64) @@ -23,16 +44,19 @@ kubectl create secret tls -n pomerium pomerium-tls --key privkey.pem --cert cert # !!! IMPORTANT !!! # YOU MUST CHANGE THE Identity Provider Client Secret # !!! IMPORTANT !!! -# kubectl create secret generic -n pomerium idp-client-secret --from-literal=idp-client-secret=REPLACE_ME +# kubectl create secret generic -n pomerium idp-client-secret --from-literal=REPLACE_ME # Create the proxy & authenticate deployment -kubectl create -f docs/docs/examples/kubernetes/authenticate.deploy.yml -kubectl create -f docs/docs/examples/kubernetes/proxy.deploy.yml +kubectl apply -f docs/docs/examples/kubernetes/authenticate.deploy.yml +kubectl apply -f docs/docs/examples/kubernetes/proxy.deploy.yml # Create the proxy & authenticate services kubectl apply -f docs/docs/examples/kubernetes/proxy.service.yml kubectl apply -f docs/docs/examples/kubernetes/authenticate.service.yml # Create and apply the Ingress; this is GKE specific kubectl apply -f docs/docs/examples/kubernetes/ingress.yml +# Alternatively, nginx-ingress can be used +# kubectl apply -f docs/docs/examples/kubernetes/ingress.nginx.yml + # When done, clean up by deleting the cluster! # gcloud container clusters delete pomerium