diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go index c7b3f1350..46c678287 100644 --- a/authenticate/authenticate.go +++ b/authenticate/authenticate.go @@ -29,7 +29,7 @@ var defaultOptions = &Options{ // Options permits the configuration of the authentication service type Options struct { - RedirectURL *url.URL `envconfig:"REDIRECT_URL" ` // e.g. auth.example.com/oauth/callback + RedirectURL *url.URL `envconfig:"REDIRECT_URL"` SharedKey string `envconfig:"SHARED_SECRET"` @@ -49,10 +49,14 @@ type Options struct { SessionLifetimeTTL time.Duration `envconfig:"SESSION_LIFETIME_TTL"` // Authentication provider configuration vars - ClientID string `envconfig:"IDP_CLIENT_ID"` // IdP ClientID - ClientSecret string `envconfig:"IDP_CLIENT_SECRET"` // IdP Secret - Provider string `envconfig:"IDP_PROVIDER"` //Provider name e.g. "oidc","okta","google",etc - ProviderURL string `envconfig:"IDP_PROVIDER_URL"` + // 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 is an optional setting corresponding to OAuth 2.0 specification's access scopes + // issuing an Access Token. Named providers are already set with good defaults. + // Most likely only overrides if using the generic OIDC provider. Scopes []string `envconfig:"IDP_SCOPE"` SkipProviderButton bool `envconfig:"SKIP_PROVIDER_BUTTON"` } diff --git a/authenticate/handlers.go b/authenticate/handlers.go index e02f76a03..afd61e424 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -197,7 +197,7 @@ func (p *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { p.SignInPage(w, r) } case sessions.ErrLifetimeExpired, sessions.ErrInvalidSession: - log.Error().Err(err).Msg("authenticate.SignIn : invalid cookie cookie") + log.Error().Err(err).Msg("authenticate.SignIn") p.sessionStore.ClearSession(w, r) if p.skipProviderButton { p.skipButtonOAuthStart(w, r) @@ -394,9 +394,9 @@ func (p *Authenticate) redeemCode(host, code string) (*sessions.SessionState, er if err != nil { return nil, err } - if session.Email == "" { - return nil, fmt.Errorf("no email included in session") - } + // if session.Email == "" { + // return nil, fmt.Errorf("no email included in session") + // } return session, nil @@ -459,7 +459,7 @@ func (p *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) log.Ctx(r.Context()).Info().Str("email", session.Email).Msg("authentication complete") err = p.sessionStore.SaveSession(w, r, session) if err != nil { - log.Ctx(r.Context()).Error().Err(err).Msg("internal error") + log.Error().Err(err).Msg("internal error") return "", httputil.HTTPError{Code: http.StatusInternalServerError, Message: "Internal Error"} } return redirect, nil @@ -476,6 +476,7 @@ func (p *Authenticate) OAuthCallback(w http.ResponseWriter, r *http.Request) { httputil.ErrorResponse(w, r, h.Message, h.Code) return default: + log.Error().Err(err).Msg("authenticate.OAuthCallback") httputil.ErrorResponse(w, r, "Internal Error", http.StatusInternalServerError) return } diff --git a/authenticate/providers/gitlab.go b/authenticate/providers/gitlab.go new file mode 100644 index 000000000..842dd55d9 --- /dev/null +++ b/authenticate/providers/gitlab.go @@ -0,0 +1,79 @@ +package providers // import "github.com/pomerium/pomerium/internal/providers" + +import ( + "context" + "time" + + oidc "github.com/pomerium/go-oidc" + "golang.org/x/oauth2" + + "github.com/pomerium/pomerium/authenticate/circuit" + "github.com/pomerium/pomerium/internal/log" +) + +const defaultGitlabProviderURL = "https://gitlab.com" + +// GitlabProvider is an implementation of the Provider interface. +type GitlabProvider struct { + *ProviderData + cb *circuit.Breaker +} + +// NewGitlabProvider returns a new Gitlab identity provider; defaults to the hosted version. +// +// Unlike other providers, `email` is not returned from the initial OIDC token. To retrieve email, +// a secondary call must be made to the user's info endpoint. Unfortunately, email is not guaranteed +// or even likely to be returned even if the user has it set as their email must be set to public. +// As pomerium is currently very email centric, I would caution using until Gitlab fixes the issue. +// +// See : +// - https://gitlab.com/gitlab-org/gitlab-ce/issues/44435#note_88150387 +// - https://docs.gitlab.com/ee/integration/openid_connect_provider.html +// - 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) { + ctx := context.Background() + if p.ProviderURL == "" { + p.ProviderURL = defaultGitlabProviderURL + } + var err error + p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) + if err != nil { + return nil, err + } + p.Scopes = []string{oidc.ScopeOpenID, "read_user"} + + 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: p.Scopes, + } + gitlabProvider := &GitlabProvider{ + ProviderData: p, + } + gitlabProvider.cb = circuit.NewBreaker(&circuit.Options{ + HalfOpenConcurrentRequests: 2, + OnStateChange: gitlabProvider.cbStateChange, + OnBackoff: gitlabProvider.cbBackoff, + ShouldTripFunc: func(c circuit.Counts) bool { return c.ConsecutiveFailures >= 3 }, + ShouldResetFunc: func(c circuit.Counts) bool { return c.ConsecutiveSuccesses >= 6 }, + BackoffDurationFunc: circuit.ExponentialBackoffDuration( + time.Duration(200)*time.Second, + time.Duration(500)*time.Millisecond), + }) + + return gitlabProvider, nil +} + +func (p *GitlabProvider) cbBackoff(duration time.Duration, reset time.Time) { + log.Info().Dur("duration", duration).Msg("authenticate/providers/gitlab.cbBackoff") + +} + +func (p *GitlabProvider) cbStateChange(from, to circuit.State) { + log.Info().Str("from", from.String()).Str("to", to.String()).Msg("authenticate/providers/gitlab.cbStateChange") +} diff --git a/authenticate/providers/google.go b/authenticate/providers/google.go index 729e2f67e..a0873b98d 100644 --- a/authenticate/providers/google.go +++ b/authenticate/providers/google.go @@ -32,16 +32,17 @@ func NewGoogleProvider(p *ProviderData) (*GoogleProvider, error) { if p.ProviderURL == "" { p.ProviderURL = defaultGoogleProviderURL } - provider, err := oidc.NewProvider(ctx, p.ProviderURL) + var err error + p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) if err != nil { return nil, err } - p.verifier = provider.Verifier(&oidc.Config{ClientID: p.ClientID}) + p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, ClientSecret: p.ClientSecret, - Endpoint: provider.Endpoint(), + Endpoint: p.provider.Endpoint(), RedirectURL: p.RedirectURL.String(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } @@ -49,12 +50,12 @@ func NewGoogleProvider(p *ProviderData) (*GoogleProvider, error) { googleProvider := &GoogleProvider{ ProviderData: p, } - // google supports a revokation endpoint + // google supports a revocation endpoint var claims struct { RevokeURL string `json:"revocation_endpoint"` } - if err := provider.Claims(&claims); err != nil { + if err := p.provider.Claims(&claims); err != nil { return nil, err } diff --git a/authenticate/providers/microsoft.go b/authenticate/providers/microsoft.go index 0016d09e4..79637a380 100644 --- a/authenticate/providers/microsoft.go +++ b/authenticate/providers/microsoft.go @@ -38,16 +38,17 @@ func NewAzureProvider(p *ProviderData) (*AzureProvider, error) { p.ProviderURL = defaultAzureProviderURL } log.Info().Msgf("provider url %s", p.ProviderURL) - provider, err := oidc.NewProvider(ctx, p.ProviderURL) + var err error + p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) if err != nil { return nil, err } - p.verifier = provider.Verifier(&oidc.Config{ClientID: p.ClientID}) + p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, ClientSecret: p.ClientSecret, - Endpoint: provider.Endpoint(), + Endpoint: p.provider.Endpoint(), RedirectURL: p.RedirectURL.String(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } @@ -60,7 +61,7 @@ func NewAzureProvider(p *ProviderData) (*AzureProvider, error) { RevokeURL string `json:"end_session_endpoint"` } - if err := provider.Claims(&claims); err != nil { + if err := p.provider.Claims(&claims); err != nil { return nil, err } diff --git a/authenticate/providers/oidc.go b/authenticate/providers/oidc.go index dce6c8a85..336a544b9 100644 --- a/authenticate/providers/oidc.go +++ b/authenticate/providers/oidc.go @@ -10,6 +10,7 @@ import ( // OIDCProvider provides a standard, OpenID Connect implementation // of an authorization identity provider. +// see : https://openid.net/specs/openid-connect-core-1_0.html type OIDCProvider struct { *ProviderData } @@ -20,15 +21,16 @@ func NewOIDCProvider(p *ProviderData) (*OIDCProvider, error) { if p.ProviderURL == "" { return nil, errors.New("missing required provider url") } - provider, err := oidc.NewProvider(ctx, p.ProviderURL) + var err error + p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) if err != nil { return nil, err } - p.verifier = provider.Verifier(&oidc.Config{ClientID: p.ClientID}) + p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, ClientSecret: p.ClientSecret, - Endpoint: provider.Endpoint(), + Endpoint: p.provider.Endpoint(), RedirectURL: p.RedirectURL.String(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } diff --git a/authenticate/providers/okta.go b/authenticate/providers/okta.go index cead032a5..a2c337fb8 100644 --- a/authenticate/providers/okta.go +++ b/authenticate/providers/okta.go @@ -28,26 +28,27 @@ func NewOktaProvider(p *ProviderData) (*OktaProvider, error) { if p.ProviderURL == "" { return nil, errors.New("missing required provider url") } - provider, err := oidc.NewProvider(ctx, p.ProviderURL) + var err error + p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) if err != nil { return nil, err } - p.verifier = provider.Verifier(&oidc.Config{ClientID: p.ClientID}) + p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) p.oauth = &oauth2.Config{ ClientID: p.ClientID, ClientSecret: p.ClientSecret, - Endpoint: provider.Endpoint(), + Endpoint: p.provider.Endpoint(), RedirectURL: p.RedirectURL.String(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } oktaProvider := OktaProvider{ProviderData: p} - // okta supports a revokation endpoint + // okta supports a revocation endpoint var claims struct { RevokeURL string `json:"revocation_endpoint"` } - if err := provider.Claims(&claims); err != nil { + if err := p.provider.Claims(&claims); err != nil { return nil, err } diff --git a/authenticate/providers/providers.go b/authenticate/providers/providers.go index 0edd043c2..57d7816de 100644 --- a/authenticate/providers/providers.go +++ b/authenticate/providers/providers.go @@ -2,8 +2,11 @@ package providers // import "github.com/pomerium/pomerium/internal/providers" import ( "context" + "encoding/json" "errors" "fmt" + "io/ioutil" + "net/http" "net/url" "time" @@ -15,17 +18,19 @@ import ( ) const ( - // AzureProviderName identifies the Azure provider + // AzureProviderName identifies the Azure identity provider AzureProviderName = "azure" - // GoogleProviderName identifies the Google provider + // GitlabProviderName identifies the GitLab identity provider + GitlabProviderName = "gitlab" + // GoogleProviderName identifies the Google identity provider GoogleProviderName = "google" - // OIDCProviderName identifes a generic OpenID connect provider + // OIDCProviderName identifies a generic OpenID connect provider OIDCProviderName = "oidc" - // OktaProviderName identifes the Okta identity provider + // OktaProviderName identifies the Okta identity provider OktaProviderName = "okta" ) -// Provider is an interface exposing functions necessary to authenticate with a given provider. +// Provider is an interface exposing functions necessary to interact with a given provider. type Provider interface { Data() *ProviderData Redeem(string) (*sessions.SessionState, error) @@ -34,38 +39,31 @@ type Provider interface { RefreshSessionIfNeeded(*sessions.SessionState) (bool, error) Revoke(*sessions.SessionState) error RefreshAccessToken(string) (string, time.Duration, error) - // Stop() } -// New returns a new identity provider based on available name. -// Defaults to google. -func New(provider string, p *ProviderData) (Provider, 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 { - case OIDCProviderName: - p, err := NewOIDCProvider(p) - if err != nil { - return nil, err - } - return p, nil case AzureProviderName: - p, err := NewAzureProvider(p) - if err != nil { - return nil, err - } - return p, nil + p, err = NewAzureProvider(pd) + case GitlabProviderName: + p, err = NewGitlabProvider(pd) + case GoogleProviderName: + p, err = NewGoogleProvider(pd) + case OIDCProviderName: + p, err = NewOIDCProvider(pd) case OktaProviderName: - p, err := NewOktaProvider(p) - if err != nil { - return nil, err - } - return p, nil + p, err = NewOktaProvider(pd) default: - p, err := NewGoogleProvider(p) - if err != nil { - return nil, err - } - return p, nil + return nil, fmt.Errorf("authenticate: provider %q not found", provider) } + if err != nil { + return nil, err + } + return p, nil } // ProviderData holds the fields associated with providers @@ -79,6 +77,7 @@ type ProviderData struct { Scopes []string SessionLifetimeTTL time.Duration + provider *oidc.Provider verifier *oidc.IDTokenVerifier oauth *oauth2.Config } @@ -100,7 +99,7 @@ func (p *ProviderData) ValidateSessionState(s *sessions.SessionState) bool { ctx := context.Background() _, err := p.verifier.Verify(ctx, s.IDToken) if err != nil { - log.Error().Err(err).Msg("authenticate/providers.ValidateSessionState : failed to verify session state") + log.Error().Err(err).Msg("authenticate/providers: failed to verify session state") return false } return true @@ -112,31 +111,47 @@ func (p *ProviderData) Redeem(code string) (*sessions.SessionState, error) { // convert authorization code into a token token, err := p.oauth.Exchange(ctx, code) if err != nil { - log.Error().Err(err).Msg("authenticate/providers.Redeem : token exchange failed") - return nil, fmt.Errorf("token exchange: %v", err) + return nil, fmt.Errorf("authenticate/providers: failed token exchange: %v", err) } s, err := p.createSessionState(ctx, token) if err != nil { - log.Error().Err(err).Msg("authenticate/providers.Redeem : unable to update session") - return nil, fmt.Errorf("unable to update session: %v", err) + 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.Info().Msg("authenticate/providers.RefreshSessionIfNeeded : session refresh not needed") + log.Debug().Msg("authenticate/providers: session refresh not needed") return false, nil } origExpiration := s.RefreshDeadline err := p.redeemRefreshToken(s) if err != nil { - log.Error().Err(err).Msg("authenticate/providers.RefreshSession") - return false, fmt.Errorf("unable to redeem refresh token: %v", err) + return false, fmt.Errorf("authenticate/providers: couldn't refresh token: %v", err) } - log.Info().Msgf("authenticate/providers.Redeem refreshed id token %s (expired on %s)", s, origExpiration) + log.Debug().Time("NewDeadline", s.RefreshDeadline).Time("OldDeadline", origExpiration).Msgf("authenticate/providers refreshed") return true, nil } @@ -152,15 +167,13 @@ func (p *ProviderData) redeemRefreshToken(s *sessions.SessionState) error { // returns a TokenSource automatically refreshing it as necessary using the provided context token, err := p.oauth.TokenSource(ctx, t).Token() if err != nil { - log.Error().Err(err).Msg("authenticate/providers failed to get token") - return fmt.Errorf("failed to get token: %v", err) + return fmt.Errorf("authenticate/providers: failed to get token: %v", err) } log.Info().Msg("authenticate/providers.oidc.redeemRefreshToken 4") newSession, err := p.createSessionState(ctx, token) if err != nil { - log.Error().Err(err).Msg("authenticate/providers unable to update session") - return fmt.Errorf("unable to update session: %v", err) + return fmt.Errorf("authenticate/providers: unable to update session: %v", err) } s.AccessToken = newSession.AccessToken s.IDToken = newSession.IDToken @@ -184,17 +197,11 @@ func (p *ProviderData) createSessionState(ctx context.Context, token *oauth2.Tok if !ok { return nil, fmt.Errorf("token response did not contain an id_token") } - log.Info(). - Bool("ctx", ctx == nil). - Bool("Verifier", p.verifier == nil). - Str("rawIDToken", rawIDToken). - Msg("authenticate/providers.oidc.createSessionState 2") // Parse and verify ID Token payload. idToken, err := p.verifier.Verify(ctx, rawIDToken) if err != nil { - log.Error().Err(err).Msg("authenticate/providers could not verify id_token") - return nil, fmt.Errorf("could not verify id_token: %v", err) + return nil, fmt.Errorf("authenticate/providers: could not verify id_token: %v", err) } // Extract custom claims. @@ -204,23 +211,27 @@ func (p *ProviderData) createSessionState(ctx context.Context, token *oauth2.Tok } // parse claims from the raw, encoded jwt token if err := idToken.Claims(&claims); err != nil { - return nil, fmt.Errorf("failed to parse id_token claims: %v", err) - } - - if claims.Email == "" { - return nil, fmt.Errorf("id_token did not contain an email") - } - if claims.Verified != nil && !*claims.Verified { - return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email) + 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: token.Expiry, - LifetimeDeadline: token.Expiry, + RefreshDeadline: idToken.Expiry, + LifetimeDeadline: idToken.Expiry, Email: claims.Email, + User: idToken.Subject, }, nil } @@ -228,7 +239,7 @@ func (p *ProviderData) createSessionState(ctx context.Context, token *oauth2.Tok // prompting the user for permission. func (p *ProviderData) RefreshAccessToken(refreshToken string) (string, time.Duration, error) { if refreshToken == "" { - return "", 0, errors.New("missing refresh token") + return "", 0, errors.New("authenticate/providers: missing refresh token") } ctx := context.Background() c := oauth2.Config{ @@ -250,14 +261,85 @@ func (p *ProviderData) RefreshAccessToken(refreshToken string) (string, time.Dur return newToken.AccessToken, newToken.Expiry.Sub(time.Now()), nil } -// Revoke enables a user to revoke her tokenn. Though many providers such as -// google and okta provide revoke endpoints, since it's not officially supported -// as part of OpenID Connect, the default implementation throws an error. +// 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 { - return errors.New("revoke not implemented") + 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/docker-compose.yml b/docker-compose.yml index 81d72f886..d997b631c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ # 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. @@ -28,7 +29,7 @@ services: - 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_PROVIDER_URL="https://accounts.google.com" - IDP_CLIENT_ID=851877082059-bfgkpj09noog7as3gpc3t7r6n9sjbgs6.apps.googleusercontent.com - IDP_CLIENT_SECRET=P34wwijKRNP3skP5ag5I12kz - SCOPE="openid email" diff --git a/docs/examples/gitlab.docker-compose.yml b/docs/examples/gitlab.docker-compose.yml new file mode 100644 index 000000000..029e5d5b8 --- /dev/null +++ b/docs/examples/gitlab.docker-compose.yml @@ -0,0 +1,102 @@ +version: "3" + +services: + # NGINX routes to pomerium's services depending on the request. + nginx: + 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: + 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 + - 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=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: + build: . + restart: always + environment: + - 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/ + # 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 + - 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-world: + image: tutum/hello-world:latest + expose: + - 80 + gitlab: + hostname: gitlab.corp.beyondperimeter.com + image: gitlab/gitlab-ce:latest + restart: always + expose: + - 443 + - 80 + - 22 + environment: + GITLAB_OMNIBUS_CONFIG: | + external_url 'https://gitlab.corp.beyondperimeter.com' + nginx['ssl_certificate'] = '/etc/gitlab/trusted-certs/corp.beyondperimeter.com.crt' + nginx['ssl_certificate_key'] = '/etc/gitlab/trusted-certs/corp.beyondperimeter.com.key' + VIRTUAL_PROTO: https + VIRTUAL_HOST: gitlab.corp.beyondperimeter.com + VIRTUAL_PORT: 443 + volumes: + - ./cert.pem:/etc/gitlab/trusted-certs/corp.beyondperimeter.com.crt + - ./privkey.pem:/etc/gitlab/trusted-certs/corp.beyondperimeter.com.key + - $HOME/gitlab/config:/etc/gitlab + - $HOME/gitlab/logs:/var/log/gitlab + - $HOME/gitlab/data:/var/opt/gitlab diff --git a/docs/guide/gitlab/gitlab-create-application.png b/docs/guide/gitlab/gitlab-create-application.png new file mode 100644 index 000000000..6cc10b0bf Binary files /dev/null and b/docs/guide/gitlab/gitlab-create-application.png differ diff --git a/docs/guide/gitlab/gitlab-credentials.png b/docs/guide/gitlab/gitlab-credentials.png new file mode 100644 index 000000000..3563ce513 Binary files /dev/null and b/docs/guide/gitlab/gitlab-credentials.png differ diff --git a/docs/guide/gitlab/gitlab-verify-access.png b/docs/guide/gitlab/gitlab-verify-access.png new file mode 100644 index 000000000..e5fcce891 Binary files /dev/null and b/docs/guide/gitlab/gitlab-verify-access.png differ diff --git a/docs/guide/identity-providers.md b/docs/guide/identity-providers.md index f90d9e110..24d211f0b 100644 --- a/docs/guide/identity-providers.md +++ b/docs/guide/identity-providers.md @@ -1,6 +1,9 @@ --- title: Identity Providers -description: This article describes how to connect pomerium to third-party identity providers / single-sign-on services. You will need to generate keys, copy these into your promerium settings, and enable the connection. +description: >- + This article describes how to connect pomerium to third-party identity + providers / single-sign-on services. You will need to generate keys, copy + these into your promerium settings, and enable the connection. --- # Identity Provider Configuration @@ -10,89 +13,19 @@ This article describes how to configure pomerium to use a third-party identity s 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: 1. Establish a **Redirect URL** with the identity provider which is called after authentication. -1. Generate a **Client ID** and **Client Secret**. -1. Configure pomerium to use the **Client ID** and **Client Secret** keys. - -## Google - -Log in to your Google account and go to the [APIs & services](https://console.developers.google.com/projectselector/apis/credentials). Navigate to **Credentials** using the left-hand menu. - -![API Manager Credentials](./google/google-credentials.png) - -On the **Credentials** page, click **Create credentials** and choose **OAuth Client ID**. - -![Create New Credentials](./google/google-create-new-credentials.png) - -On the **Create Client ID** page, select **Web application**. In the new fields that display, set the following parameters: - -| Field | Description | -| ------------------------ | ----------------------------------------- | -| Name | The name of your web app | -| Authorized redirect URIs | `https://${redirect-url}/oauth2/callback` | - -![Web App Credentials Configuration](./google/google-create-client-id-config.png) - -Click **Create** to proceed. - -Your `Client ID` and `Client Secret` will be displayed: - -![OAuth Client ID and Secret](./google/google-oauth-client-info.png) - -Set `Client ID` and `Client Secret` in Pomerium's settings. Your [environmental variables] should look something like this. - -```bash -REDIRECT_URL="https://sso-auth.corp.beyondperimeter.com/oauth2/callback" -IDP_PROVIDER="google" -IDP_PROVIDER_URL="https://accounts.google.com" -IDP_CLIENT_ID="yyyy.apps.googleusercontent.com" -IDP_CLIENT_SECRET="xxxxxx" -``` - -## Okta - -[Log in to your Okta account](https://login.okta.com) and head to your Okta dashboard. Select **Applications** on the top menu. On the Applications page, click the **Add Application** button to create a new app. - -![Okta Applications Dashboard](./okta/okta-app-dashboard.png) - -On the **Create New Application** page, select the **Web** for your application. - -![Okta Create Application Select Platform](./okta/okta-create-app-platform.png) - -Next, provide the following information for your application settings: - -| Field | Description | -| ---------------------------- | ----------------------------------------------------- | -| Name | The name of your application. | -| Base URIs (optional) | The domain(s) of your application. | -| Login redirect URIs | `https://${redirect-url}/oauth2/callback`. | -| Group assignments (optional) | The user groups that can sign in to this application. | -| Grant type allowed | **You must enable Refresh Token.** | - -![Okta Create Application Settings](./okta/okta-create-app-settings.png) - -Click **Done** to proceed. You'll be taken to the **General** page of your app. - -Go to the **General** page of your app and scroll down to the **Client Credentials** section. This section contains the **Client ID** and **Client Secret** to be used in the next step. -![Okta Client ID and Secret](./okta/okta-client-id-and-secret.png) - -At this point, you will configure the integration from the Pomerium side. Your [environmental variables] should look something like this. - -```bash -REDIRECT_URL="https://sso-auth.corp.beyondperimeter.com/oauth2/callback" -IDP_PROVIDER="okta" -IDP_PROVIDER_URL="https://dev-108295-admin.oktapreview.com/" -IDP_CLIENT_ID="0oairksnr0C0fEJ7l0h7" -IDP_CLIENT_SECRET="xxxxxx" -``` +2. Generate a **Client ID** and **Client Secret**. +3. Configure pomerium to use the **Client ID** and **Client Secret** keys. ## Azure If you plan on allowing users to log in using a Microsoft Azure Active Directory account, either from your company or from external directories, you must register your application through the Microsoft Azure portal. If you don't have a Microsoft Azure account, you can [signup](https://azure.microsoft.com/en-us/free) for free. -You can access the Azure management portal from your Microsoft service, or visit [https://portal.azure.com](https://portal.azure.com) and sign in to Azure using the global administrator account used to create the Office 365 organization. +You can access the Azure management portal from your Microsoft service, or visit and sign in to Azure using the global administrator account used to create the Office 365 organization. ::: tip + There is no way to create an application that integrates with Microsoft Azure AD without having **your own** Microsoft Azure AD instance. + ::: If you have an Office 365 account, you can use the account's Azure AD instance instead of creating a new one. To find your Office 365 account's Azure AD instance: @@ -125,21 +58,20 @@ Next you will need to create a key which will be used as the **Client Secret** i Enter a name for the key and choose the desired duration. ::: tip + If you choose an expiring key, make sure to record the expiration date in your calendar, as you will need to renew the key (get a new one) before that day in order to ensure users don't experience a service interruption. + ::: Click on **Save** and the key will be displayed. **Make sure to copy the value of this key before leaving this screen**, otherwise you may need to create a new key. This value is used as the **Client Secret**. ![Creating a Key](./microsoft/azure-create-key.png) -Next you need to ensure that the Pomerium's Redirect URL is listed in allowed reply URLs for the created application. Navigate to **Azure Active Directory** -> **Apps registrations** and select your app. Then click **Settings** -> **Reply URLs** and add Pomerium's redirect URL. For example, -`https://sso-auth.corp.beyondperimeter.com/oauth2/callback`. +Next you need to ensure that the Pomerium's Redirect URL is listed in allowed reply URLs for the created application. Navigate to **Azure Active Directory** -> **Apps registrations** and select your app. Then click **Settings** -> **Reply URLs** and add Pomerium's redirect URL. For example, `https://sso-auth.corp.beyondperimeter.com/oauth2/callback`. ![Add Reply URL](./microsoft/azure-redirect-url.png) -The final, and most unique step to Azure AD provider, is to take note of your specific endpoint. Navigate to **Azure Active Directory** -> **Apps registrations** and select your app. -![Application dashboard](./microsoft/azure-application-dashbaord.png) -Click on **Endpoints** +The final, and most unique step to Azure AD provider, is to take note of your specific endpoint. Navigate to **Azure Active Directory** -> **Apps registrations** and select your app. ![Application dashboard](./microsoft/azure-application-dashbaord.png) Click on **Endpoints** ![Endpoint details](./microsoft/azure-endpoints.png) @@ -156,7 +88,123 @@ IDP_PROVIDER="azure" IDP_PROVIDER_URL="https://login.microsoftonline.com/{REPLACE-ME-SEE-ABOVE}/v2.0" IDP_CLIENT_ID="REPLACE-ME" IDP_CLIENT_SECRET="REPLACE-ME" +``` +## Gitlab + +:::warning + +Gitlab currently does not provide callers with a user email, under any scope, to a caller unless that user has selected her email to be public. Because Pomerium is by nature very centric, users are cautioned from using Pomerium until [this gitlab bug](https://gitlab.com/gitlab-org/gitlab-ce/issues/44435#note_88150387) is fixed. + +::: + +Log in to your Gitlab account and go to the [APIs & services](https://console.developers.google.com/projectselector/apis/credentials). + +Navigate to **User Settings** then **Applications** using the left-hand menu. + +On the **Applications** page, add a new application by setting the following parameters: + +Field | Description +------------ | -------------------------------------------- +Name | The name of your web app +Redirect URI | `https://${redirect-url}/oauth2/callback` +Scopes | **Must** select **read_user** and **openid** + +![Create New Credentials](./gitlab/gitlab-create-application.png) + +1.Click **Save Application** to proceed. + +Your `Client ID` and `Client Secret` will be displayed: + +![Gitlab OAuth Client ID and Secret](./gitlab/gitlab-credentials.png) + +Set `Client ID` and `Client Secret` in Pomerium's settings. Your [environmental variables] should look something like this. + +```bash +REDIRECT_URL="https://sso-auth.corp.beyondperimeter.com/oauth2/callback" +IDP_PROVIDER="gitlab" +# NOTE!!! Provider url is optional, but should be set if you are running an on-premise instance +# defaults to : https://gitlab.com, a local copy would look something like `http://gitlab.corp.beyondperimeter.com` +IDP_PROVIDER_URL="https://gitlab.com" +IDP_CLIENT_ID="yyyy" +IDP_CLIENT_SECRET="xxxxxx" +``` + +When a user first uses pomerium to login, they will be presented with an authorization screen similar to the following. + +![gitlab access authorization screen](./gitlab/gitlab-verify-access.png) + +## Google + +Log in to your Google account and go to the [APIs & services](https://console.developers.google.com/projectselector/apis/credentials). Navigate to **Credentials** using the left-hand menu. + +![API Manager Credentials](./google/google-credentials.png) + +On the **Credentials** page, click **Create credentials** and choose **OAuth Client ID**. + +![Create New Credentials](./google/google-create-new-credentials.png) + +On the **Create Client ID** page, select **Web application**. In the new fields that display, set the following parameters: + +Field | Description +------------------------ | ----------------------------------------- +Name | The name of your web app +Authorized redirect URIs | `https://${redirect-url}/oauth2/callback` + +![Web App Credentials Configuration](./google/google-create-client-id-config.png) + +Click **Create** to proceed. + +Your `Client ID` and `Client Secret` will be displayed: + +![OAuth Client ID and Secret](./google/google-oauth-client-info.png) + +Set `Client ID` and `Client Secret` in Pomerium's settings. Your [environmental variables] should look something like this. + +```bash +REDIRECT_URL="https://sso-auth.corp.beyondperimeter.com/oauth2/callback" +IDP_PROVIDER="google" +IDP_PROVIDER_URL="https://accounts.google.com" +IDP_CLIENT_ID="yyyy.apps.googleusercontent.com" +IDP_CLIENT_SECRET="xxxxxx" +``` + +## Okta + +[Log in to your Okta account](https://login.okta.com) and head to your Okta dashboard. Select **Applications** on the top menu. On the Applications page, click the **Add Application** button to create a new app. + +![Okta Applications Dashboard](./okta/okta-app-dashboard.png) + +On the **Create New Application** page, select the **Web** for your application. + +![Okta Create Application Select Platform](./okta/okta-create-app-platform.png) + +Next, provide the following information for your application settings: + +Field | Description +---------------------------- | ----------------------------------------------------- +Name | The name of your application. +Base URIs (optional) | The domain(s) of your application. +Login redirect URIs | `https://${redirect-url}/oauth2/callback`. +Group assignments (optional) | The user groups that can sign in to this application. +Grant type allowed | **You must enable Refresh Token.** + +![Okta Create Application Settings](./okta/okta-create-app-settings.png) + +Click **Done** to proceed. You'll be taken to the **General** page of your app. + +Go to the **General** page of your app and scroll down to the **Client Credentials** section. This section contains the **Client ID** and **Client Secret** to be used in the next step. + +![Okta Client ID and Secret](./okta/okta-client-id-and-secret.png) + +At this point, you will configure the integration from the Pomerium side. Your [environmental variables] should look something like this. + +```bash +REDIRECT_URL="https://sso-auth.corp.beyondperimeter.com/oauth2/callback" +IDP_PROVIDER="okta" +IDP_PROVIDER_URL="https://dev-108295-admin.oktapreview.com/" +IDP_CLIENT_ID="0oairksnr0C0fEJ7l0h7" +IDP_CLIENT_SECRET="xxxxxx" ``` [environmental variables]: https://en.wikipedia.org/wiki/Environment_variable diff --git a/env.example b/env.example index c0a41a6e1..8357757f9 100644 --- a/env.example +++ b/env.example @@ -24,24 +24,30 @@ export COOKIE_SECRET=uPGHo1ujND/k3B9V6yr52Gweq3RRYfFho98jxDG5Br8= # Identity Provider Settings -# OKTA -# export IDP_PROVIDER="okta -# export IDP_CLIENT_ID="REPLACEME" -# export IDP_CLIENT_SECRET="REPLACEME" -# export IDP_PROVIDER_URL="https://REPLACEME.oktapreview.com/oauth2/default" - # Azure # export IDP_PROVIDER="azure" # export IDP_PROVIDER_URL="https://login.microsoftonline.com/REPLACEME/v2.0" # export IDP_CLIENT_ID="REPLACEME # export IDP_CLIENT_SECRET="REPLACEME" +# Gitlab +# export IDP_PROVIDER="gitlab" +# export IDP_PROVIDER_URL="https://gitlab.onprem.example.com" # optional, defaults to `https://gitlab.com` +# export IDP_CLIENT_ID="REPLACEME +# export IDP_CLIENT_SECRET="REPLACEME" + ## GOOGLE export IDP_PROVIDER="google" export IDP_PROVIDER_URL="https://accounts.google.com" # optional for google export IDP_CLIENT_ID="REPLACE-ME.googleusercontent.com" export IDP_CLIENT_SECRET="REPLACEME" +# OKTA +# export IDP_PROVIDER="okta +# export IDP_CLIENT_ID="REPLACEME" +# export IDP_CLIENT_SECRET="REPLACEME" +# export IDP_PROVIDER_URL="https://REPLACEME.oktapreview.com/oauth2/default" + # export SCOPE="openid email" # generally, you want the default OIDC scopes # k/v seperated list of simple routes. If no scheme is set, HTTPS will be used. diff --git a/internal/sessions/cookie_store.go b/internal/sessions/cookie_store.go index 43bacc765..90bb98627 100644 --- a/internal/sessions/cookie_store.go +++ b/internal/sessions/cookie_store.go @@ -11,7 +11,7 @@ import ( ) // ErrInvalidSession is an error for invalid sessions. -var ErrInvalidSession = errors.New("invalid session") +var ErrInvalidSession = errors.New("internal/sessions: invalid session") // CSRFStore has the functions for setting, getting, and clearing the CSRF cookie type CSRFStore interface {