From 627a591824dd0a50c369e22d5c845e083b38170d Mon Sep 17 00:00:00 2001 From: Bobby DeSimone <1544881+desimone@users.noreply.github.com> Date: Thu, 23 Apr 2020 10:36:24 -0700 Subject: [PATCH] identity: abstract identity providers by type (#560) Signed-off-by: Bobby DeSimone --- .golangci.yml | 5 +- authenticate/authenticate.go | 7 +- authenticate/handlers.go | 6 +- authenticate/handlers_test.go | 3 +- internal/identity/errors.go | 12 - internal/identity/gitlab.go | 101 ------- internal/identity/microsoft.go | 97 ------- .../identity/{ => oauth/github}/github.go | 115 ++++---- internal/identity/oauth/options.go | 32 +++ internal/identity/oidc.go | 40 --- internal/identity/oidc/azure/microsoft.go | 87 ++++++ internal/identity/oidc/errors.go | 16 ++ internal/identity/oidc/gitlab/gitlab.go | 97 +++++++ internal/identity/{ => oidc/google}/google.go | 92 ++++--- internal/identity/oidc/oidc.go | 216 +++++++++++++++ internal/identity/oidc/okta/okta.go | 88 ++++++ internal/identity/oidc/onelogin/onelogin.go | 78 ++++++ internal/identity/okta.go | 93 ------- internal/identity/onelogin.go | 83 ------ internal/identity/providers.go | 251 +++--------------- 20 files changed, 773 insertions(+), 746 deletions(-) delete mode 100644 internal/identity/errors.go delete mode 100644 internal/identity/gitlab.go delete mode 100644 internal/identity/microsoft.go rename internal/identity/{ => oauth/github}/github.go (62%) create mode 100644 internal/identity/oauth/options.go delete mode 100644 internal/identity/oidc.go create mode 100644 internal/identity/oidc/azure/microsoft.go create mode 100644 internal/identity/oidc/errors.go create mode 100644 internal/identity/oidc/gitlab/gitlab.go rename internal/identity/{ => oidc/google}/google.go (55%) create mode 100644 internal/identity/oidc/oidc.go create mode 100644 internal/identity/oidc/okta/okta.go create mode 100644 internal/identity/oidc/onelogin/onelogin.go delete mode 100644 internal/identity/okta.go delete mode 100644 internal/identity/onelogin.go diff --git a/.golangci.yml b/.golangci.yml index a7b99f7d0..159e03a8e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -219,7 +219,10 @@ issues: text: "please use NewService instead" linters: - staticcheck - + - path: internal/identity/oauth/github/github.go + text: "Potential hardcoded credentials" + linters: + - gosec # Independently from option `exclude` we use default exclude patterns, # it can be disabled by this option. To list all # excluded by default patterns execute `golangci-lint run --help`. diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go index 4f9411374..5476a0999 100644 --- a/authenticate/authenticate.go +++ b/authenticate/authenticate.go @@ -19,6 +19,7 @@ import ( "github.com/pomerium/pomerium/internal/grpc" "github.com/pomerium/pomerium/internal/grpc/cache/client" "github.com/pomerium/pomerium/internal/identity" + "github.com/pomerium/pomerium/internal/identity/oauth" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/sessions/cache" "github.com/pomerium/pomerium/internal/sessions/cookie" @@ -155,9 +156,8 @@ func New(opts config.Options) (*Authenticate, error) { redirectURL, _ := urlutil.DeepCopy(opts.AuthenticateURL) redirectURL.Path = opts.AuthenticateCallbackPath // configure our identity provider - provider, err := identity.New( - opts.Provider, - &identity.Provider{ + provider, err := identity.NewAuthenticator( + oauth.Options{ RedirectURL: redirectURL, ProviderName: opts.Provider, ProviderURL: opts.ProviderURL, @@ -166,6 +166,7 @@ func New(opts config.Options) (*Authenticate, error) { Scopes: opts.Scopes, ServiceAccount: opts.ServiceAccount, }) + if err != nil { return nil, err } diff --git a/authenticate/handlers.go b/authenticate/handlers.go index 838f1830c..bcbf4ffe7 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -16,7 +16,7 @@ import ( "github.com/pomerium/csrf" "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/identity" + "github.com/pomerium/pomerium/internal/identity/oidc" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/sessions" @@ -233,7 +233,7 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error { // first, try to revoke the session if implemented err = a.provider.Revoke(r.Context(), s.AccessToken) - if err != nil && !errors.Is(err, identity.ErrRevokeNotImplemented) { + if err != nil && !errors.Is(err, oidc.ErrRevokeNotImplemented) { return httputil.NewError(http.StatusBadRequest, err) } @@ -244,7 +244,7 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error { params.Add("post_logout_redirect_uri", redirectString) endSessionURL.RawQuery = params.Encode() redirectString = endSessionURL.String() - } else if !errors.Is(err, identity.ErrSignoutNotImplemented) { + } else if !errors.Is(err, oidc.ErrSignoutNotImplemented) { return httputil.NewError(http.StatusBadRequest, err) } diff --git a/authenticate/handlers_test.go b/authenticate/handlers_test.go index 9b5d38803..0e5de8cda 100644 --- a/authenticate/handlers_test.go +++ b/authenticate/handlers_test.go @@ -18,6 +18,7 @@ import ( "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/identity" + "github.com/pomerium/pomerium/internal/identity/oidc" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/sessions/cookie" mstore "github.com/pomerium/pomerium/internal/sessions/mock" @@ -192,7 +193,7 @@ func TestAuthenticate_SignOut(t *testing.T) { {"good post", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusFound, ""}, {"failed revoke", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: OH NO\"}\n"}, {"load session error", http.MethodPost, errors.New("error"), "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: error\"}\n"}, - {"bad redirect uri", http.MethodPost, nil, "corp.pomerium.io/", "sig", "ts", identity.MockProvider{LogOutError: identity.ErrSignoutNotImplemented}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: corp.pomerium.io/ url does contain a valid scheme\"}\n"}, + {"bad redirect uri", http.MethodPost, nil, "corp.pomerium.io/", "sig", "ts", identity.MockProvider{LogOutError: oidc.ErrSignoutNotImplemented}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: corp.pomerium.io/ url does contain a valid scheme\"}\n"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/identity/errors.go b/internal/identity/errors.go deleted file mode 100644 index f53c62129..000000000 --- a/internal/identity/errors.go +++ /dev/null @@ -1,12 +0,0 @@ -package identity - -import "errors" - -// ErrRevokeNotImplemented error type when Revoke method is not implemented -// by an identity provider -var ErrRevokeNotImplemented = errors.New("internal/identity: revoke not implemented") - -// ErrSignoutNotImplemented error type when end session is not implemented -// by an identity provider -// https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPInitiated -var ErrSignoutNotImplemented = errors.New("internal/identity: end session not implemented") diff --git a/internal/identity/gitlab.go b/internal/identity/gitlab.go deleted file mode 100644 index 82f3ea145..000000000 --- a/internal/identity/gitlab.go +++ /dev/null @@ -1,101 +0,0 @@ -package identity // import "github.com/pomerium/pomerium/internal/identity" - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - - oidc "github.com/coreos/go-oidc" - "golang.org/x/oauth2" - - "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/sessions" - "github.com/pomerium/pomerium/internal/version" -) - -const ( - defaultGitLabProviderURL = "https://gitlab.com" - groupPath = "/api/v4/groups" -) - -// GitLabProvider is an implementation of the OAuth Provider -type GitLabProvider struct { - *Provider -} - -// NewGitLabProvider returns a new GitLabProvider. -// https://www.pomerium.io/docs/identity-providers/gitlab.html -func NewGitLabProvider(p *Provider) (*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 - } - - if len(p.Scopes) == 0 { - p.Scopes = []string{oidc.ScopeOpenID, "api", "read_user", "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: p.Scopes, - } - gp := &GitLabProvider{Provider: p} - - if err := p.provider.Claims(&gp); err != nil { - return nil, err - } - gp.UserGroupFn = gp.UserGroups - return gp, nil -} - -// UserGroups returns a slice of groups for the user. -// -// By default, this request returns 20 results at a time because the API results are paginated. -// https://docs.gitlab.com/ee/api/groups.html#list-groups -func (p *GitLabProvider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { - if s == nil || s.AccessToken == nil { - return nil, errors.New("identity/gitlab: user session cannot be empty") - } - - var response []struct { - ID json.Number `json:"id"` - Name string `json:"name,omitempty"` - Path string `json:"path,omitempty"` - Description string `json:"description,omitempty"` - Visibility string `json:"visibility,omitempty"` - ShareWithGroupLock bool `json:"share_with_group_lock,omitempty"` - RequireTwoFactorAuthentication bool `json:"require_two_factor_authentication,omitempty"` - SubgroupCreationLevel string `json:"subgroup_creation_level,omitempty"` - FullName string `json:"full_name,omitempty"` - FullPath string `json:"full_path,omitempty"` - } - userGroupURL := p.ProviderURL + groupPath - headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.AccessToken.AccessToken)} - err := httputil.Client(ctx, http.MethodGet, userGroupURL, version.UserAgent(), headers, nil, &response) - if err != nil { - return nil, err - } - - var groups []string - log.Debug().Interface("response", response).Msg("identity/gitlab: groups") - - for _, group := range response { - groups = append(groups, group.ID.String()) - } - - return groups, nil -} diff --git a/internal/identity/microsoft.go b/internal/identity/microsoft.go deleted file mode 100644 index 5cf4f9916..000000000 --- a/internal/identity/microsoft.go +++ /dev/null @@ -1,97 +0,0 @@ -package identity - -import ( - "context" - "errors" - "fmt" - "net/http" - "time" - - oidc "github.com/coreos/go-oidc" - "golang.org/x/oauth2" - - "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/sessions" - "github.com/pomerium/pomerium/internal/version" -) - -// defaultAzureProviderURL Users with both a personal Microsoft -// account and a work or school account from Azure Active Directory (Azure AD) -// an sign in to the application. -const defaultAzureProviderURL = "https://login.microsoftonline.com/common" -const defaultAzureGroupURL = "https://graph.microsoft.com/v1.0/me/memberOf" - -// AzureProvider is an implementation of the Provider interface -type AzureProvider struct { - *Provider -} - -// NewAzureProvider returns a new AzureProvider and sets the provider url endpoints. -// https://www.pomerium.io/docs/identity-providers.html#azure-active-directory -func NewAzureProvider(p *Provider) (*AzureProvider, error) { - ctx := context.Background() - if p.ProviderURL == "" { - p.ProviderURL = defaultAzureProviderURL - } - var err error - p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) - if err != nil { - return nil, err - } - if len(p.Scopes) == 0 { - p.Scopes = []string{oidc.ScopeOpenID, "profile", "email", "offline_access", "Group.Read.All"} - } - 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, - } - - azureProvider := &AzureProvider{Provider: p} - if err := p.provider.Claims(&azureProvider); err != nil { - return nil, err - } - - p.UserGroupFn = azureProvider.UserGroups - - return azureProvider, nil -} - -// GetSignInURL returns the sign in url with typical oauth parameters -func (p *AzureProvider) GetSignInURL(state string) string { - return p.oauth.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "select_account")) -} - -// UserGroups returns a slice of group names a given user is in. -// `Directory.Read.All` is required. -// https://docs.microsoft.com/en-us/graph/api/resources/directoryobject?view=graph-rest-1.0 -// https://docs.microsoft.com/en-us/graph/api/user-list-memberof?view=graph-rest-1.0 -func (p *AzureProvider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { - if s == nil || s.AccessToken == nil { - return nil, errors.New("identity/azure: session cannot be nil") - } - var response struct { - Groups []struct { - ID string `json:"id"` - Description string `json:"description,omitempty"` - DisplayName string `json:"displayName"` - CreatedDateTime time.Time `json:"createdDateTime,omitempty"` - GroupTypes []string `json:"groupTypes,omitempty"` - } `json:"value"` - } - headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.AccessToken.AccessToken)} - err := httputil.Client(ctx, http.MethodGet, defaultAzureGroupURL, version.UserAgent(), headers, nil, &response) - if err != nil { - return nil, err - } - var groups []string - for _, group := range response.Groups { - log.Debug().Str("DisplayName", group.DisplayName).Str("ID", group.ID).Msg("identity/microsoft: group") - groups = append(groups, group.ID) - } - return groups, nil -} diff --git a/internal/identity/github.go b/internal/identity/oauth/github/github.go similarity index 62% rename from internal/identity/github.go rename to internal/identity/oauth/github/github.go index e927590e0..e7088c8ec 100644 --- a/internal/identity/github.go +++ b/internal/identity/oauth/github/github.go @@ -1,4 +1,7 @@ -package identity +// Package github implements OAuth2 based authentication for github +// +// https://www.pomerium.io/docs/identity-providers/github.html +package github import ( "context" @@ -10,68 +13,75 @@ import ( "strings" "time" + "golang.org/x/oauth2" + "gopkg.in/square/go-jose.v2/jwt" + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/identity/oauth" + pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/version" - - "golang.org/x/oauth2" - "gopkg.in/square/go-jose.v2/jwt" ) +// Name identifies the GitHub identity provider +const Name = "github" + const ( - defaultGitHubProviderURL = "https://github.com" - githubAPIURL = "https://api.github.com" - userPath = "/user" - teamPath = "/user/teams" - revokePath = "/applications/%s/grant" - emailPath = "/user/emails" + defaultProviderURL = "https://github.com" + githubAPIURL = "https://api.github.com" + userPath = "/user" + teamPath = "/user/teams" + revokePath = "/applications/%s/grant" + emailPath = "/user/emails" + // https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps + authURL = "/login/oauth/authorize" + tokenURL = "/login/oauth/access_token" // since github doesn't implement oidc, we need this to refresh the user session refreshDeadline = time.Minute * 60 ) -// GitHubProvider is an implementation of the OAuth Provider. -type GitHubProvider struct { - *Provider +// https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ +var defaultScopes = []string{"user:email", "read:org"} + +// Provider is an implementation of the OAuth Provider. +type Provider struct { + *pom_oidc.Provider userEndpoint string } -// NewGitHubProvider returns a new GitHubProvider. -func NewGitHubProvider(p *Provider) (*GitHubProvider, error) { - if p.ProviderURL == "" { - p.ProviderURL = defaultGitHubProviderURL +// New instantiates an OAuth2 provider for Github. +func New(ctx context.Context, o *oauth.Options) (*Provider, error) { + var p Provider + if o.ProviderURL == "" { + o.ProviderURL = defaultProviderURL } - - if len(p.Scopes) == 0 { - p.Scopes = []string{"user:email", "read:org"} + if len(o.Scopes) == 0 { + o.Scopes = defaultScopes } - - p.oauth = &oauth2.Config{ - ClientID: p.ClientID, - ClientSecret: p.ClientSecret, + p.Oauth = &oauth2.Config{ + ClientID: o.ClientID, + ClientSecret: o.ClientSecret, Endpoint: oauth2.Endpoint{ - AuthURL: p.ProviderURL + "/login/oauth/authorize", - TokenURL: p.ProviderURL + "/login/oauth/access_token", + AuthURL: o.ProviderURL + authURL, + TokenURL: o.ProviderURL + tokenURL, }, - RedirectURL: p.RedirectURL.String(), - Scopes: p.Scopes, - } - gp := &GitHubProvider{ - Provider: p, - userEndpoint: githubAPIURL + userPath, + RedirectURL: o.RedirectURL.String(), + Scopes: o.Scopes, } + p.userEndpoint = githubAPIURL + userPath - return gp, nil + return &p, nil } // Authenticate creates an identity session with github from a authorization code, and follows up // call to the user and user group endpoint with the -func (p *GitHubProvider) Authenticate(ctx context.Context, code string) (*sessions.State, error) { - resp, err := p.oauth.Exchange(ctx, code) +func (p *Provider) Authenticate(ctx context.Context, code string) (*sessions.State, error) { + resp, err := p.Oauth.Exchange(ctx, code) if err != nil { - return nil, fmt.Errorf("identity/github: token exchange failed %v", err) + return nil, fmt.Errorf("github: token exchange failed %v", err) } s := &sessions.State{ @@ -93,34 +103,34 @@ func (p *GitHubProvider) Authenticate(ctx context.Context, code string) (*sessio // updateSessionState will get the user information from github and also retrieve the user's team(s) // // https://developer.github.com/v3/users/#get-the-authenticated-user -func (p *GitHubProvider) updateSessionState(ctx context.Context, s *sessions.State) error { +func (p *Provider) updateSessionState(ctx context.Context, s *sessions.State) error { if s == nil || s.AccessToken == nil { - return errors.New("identity/github: user session cannot be empty") + return errors.New("github: user session cannot be empty") } accessToken := s.AccessToken.AccessToken err := p.userInfo(ctx, accessToken, s) if err != nil { - return fmt.Errorf("identity/github: could not retrieve user info %w", err) + return fmt.Errorf("github: could not retrieve user info %w", err) } err = p.userEmail(ctx, accessToken, s) if err != nil { - return fmt.Errorf("identity/github: could not retrieve user email %w", err) + return fmt.Errorf("github: could not retrieve user email %w", err) } err = p.userTeams(ctx, accessToken, s) if err != nil { - return fmt.Errorf("identity/github: could not retrieve groups %w", err) + return fmt.Errorf("github: could not retrieve groups %w", err) } return nil } // Refresh renews a user's session by making a new userInfo request. -func (p *GitHubProvider) Refresh(ctx context.Context, s *sessions.State) (*sessions.State, error) { +func (p *Provider) Refresh(ctx context.Context, s *sessions.State) (*sessions.State, error) { if s.AccessToken == nil { - return nil, errors.New("identity/github: missing oauth2 access token") + return nil, errors.New("github: missing oauth2 access token") } if err := p.updateSessionState(ctx, s); err != nil { return nil, err @@ -133,7 +143,7 @@ func (p *GitHubProvider) Refresh(ctx context.Context, s *sessions.State) (*sessi // // https://developer.github.com/v3/teams/#list-user-teams // https://developer.github.com/v3/auth/ -func (p *GitHubProvider) userTeams(ctx context.Context, at string, s *sessions.State) error { +func (p *Provider) userTeams(ctx context.Context, at string, s *sessions.State) error { var response []struct { ID json.Number `json:"id"` @@ -152,8 +162,7 @@ func (p *GitHubProvider) userTeams(ctx context.Context, at string, s *sessions.S return err } - log.Debug().Interface("teams", response).Msg("identity/github: user teams") - + log.Debug().Interface("teams", response).Msg("github: user teams") s.Groups = nil for _, org := range response { s.Groups = append(s.Groups, org.ID.String()) @@ -167,7 +176,7 @@ func (p *GitHubProvider) userTeams(ctx context.Context, at string, s *sessions.S // // https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user // https://developer.github.com/v3/auth/ -func (p *GitHubProvider) userEmail(ctx context.Context, at string, s *sessions.State) error { +func (p *Provider) userEmail(ctx context.Context, at string, s *sessions.State) error { // response represents the github user email // https://developer.github.com/v3/users/emails/#response var response []struct { @@ -183,7 +192,7 @@ func (p *GitHubProvider) userEmail(ctx context.Context, at string, s *sessions.S return err } - log.Debug().Interface("emails", response).Msg("identity/github: user emails") + log.Debug().Interface("emails", response).Msg("github: user emails") for _, email := range response { if email.Primary && email.Verified { s.Email = email.Email @@ -194,7 +203,7 @@ func (p *GitHubProvider) userEmail(ctx context.Context, at string, s *sessions.S return nil } -func (p *GitHubProvider) userInfo(ctx context.Context, at string, s *sessions.State) error { +func (p *Provider) userInfo(ctx context.Context, at string, s *sessions.State) error { var response struct { ID int `json:"id"` Login string `json:"login"` @@ -224,19 +233,19 @@ func (p *GitHubProvider) userInfo(ctx context.Context, at string, s *sessions.St // gave pomerium application during authorization. // // https://developer.github.com/v3/apps/oauth_applications/#delete-an-app-authorization -func (p *GitHubProvider) Revoke(ctx context.Context, token *oauth2.Token) error { +func (p *Provider) Revoke(ctx context.Context, token *oauth2.Token) error { // build the basic authentication request - basicAuth := url.UserPassword(p.ClientID, p.ClientSecret) + basicAuth := url.UserPassword(p.Oauth.ClientID, p.Oauth.ClientSecret) revokeURL := url.URL{ Scheme: "https", User: basicAuth, Host: "api.github.com", - Path: fmt.Sprintf(revokePath, p.ClientID), + Path: fmt.Sprintf(revokePath, p.Oauth.ClientID), } reqBody := strings.NewReader(fmt.Sprintf(`{"access_token": "%s"}`, token.AccessToken)) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, revokeURL.String(), reqBody) if err != nil { - return errors.New("identity/github could not create revoke request") + return errors.New("github: could not create revoke request") } req.Header.Set("Content-Type", "application/json") diff --git a/internal/identity/oauth/options.go b/internal/identity/oauth/options.go new file mode 100644 index 000000000..df03d13b2 --- /dev/null +++ b/internal/identity/oauth/options.go @@ -0,0 +1,32 @@ +// Package oauth provides support for making OAuth2 authorized and authenticated +// HTTP requests, as specified in RFC 6749. It can additionally grant +// authorization with Bearer JWT. +package oauth + +import "net/url" + +// Options contains the fields required for an OAuth 2.0 (inc. OIDC) auth flow. +// +// https://tools.ietf.org/html/rfc6749 +// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +type Options struct { + ProviderName string + + // ProviderURL is the endpoint to look for .well-known/openid-configuration + // OAuth2 related endpoints and will be autoconfigured based off this URL + ProviderURL string + + // ClientID is the application's ID. + ClientID string + // ClientSecret is the application's secret. + ClientSecret string + // RedirectURL is the URL to redirect users going through + // the OAuth flow, after the resource owner's URLs. + RedirectURL *url.URL + // Scope specifies optional requested permissions. + Scopes []string + + // ServiceAccount can be set for those providers that require additional + // credentials or tokens to do follow up API calls (e.g. Google) + ServiceAccount string +} diff --git a/internal/identity/oidc.go b/internal/identity/oidc.go deleted file mode 100644 index 7e22488d5..000000000 --- a/internal/identity/oidc.go +++ /dev/null @@ -1,40 +0,0 @@ -package identity - -import ( - "context" - - oidc "github.com/coreos/go-oidc" - "golang.org/x/oauth2" -) - -// OIDCProvider provides a standard, OpenID Connect implementation -// of an authorization identity provider. -// https://openid.net/specs/openid-connect-core-1_0.html -type OIDCProvider struct { - *Provider -} - -// NewOIDCProvider creates a new instance of a generic OpenID Connect provider. -func NewOIDCProvider(p *Provider) (*OIDCProvider, error) { - ctx := context.Background() - if p.ProviderURL == "" { - return nil, ErrMissingProviderURL - } - var err error - p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) - 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: p.Scopes, - } - return &OIDCProvider{Provider: p}, nil -} diff --git a/internal/identity/oidc/azure/microsoft.go b/internal/identity/oidc/azure/microsoft.go new file mode 100644 index 000000000..c6bbee8b7 --- /dev/null +++ b/internal/identity/oidc/azure/microsoft.go @@ -0,0 +1,87 @@ +// Package azure implements OpenID Connect for Microsoft Azure +// +// https://www.pomerium.io/docs/identity-providers/azure.html +package azure + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "golang.org/x/oauth2" + + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/identity/oauth" + pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/sessions" + "github.com/pomerium/pomerium/internal/version" +) + +// Name identifies the Azure identity provider +const Name = "azure" + +// defaultProviderURL Users with both a personal Microsoft +// account and a work or school account from Azure Active Directory (Azure AD) +// an sign in to the application. +const defaultProviderURL = "https://login.microsoftonline.com/common" +const defaultGroupURL = "https://graph.microsoft.com/v1.0/me/memberOf" + +// Provider is an Azure implementation of the Authenticator interface. +type Provider struct { + *pom_oidc.Provider +} + +// New instantiates an OpenID Connect (OIDC) provider for Azure. +func New(ctx context.Context, o *oauth.Options) (*Provider, error) { + var p Provider + var err error + if o.ProviderURL == "" { + o.ProviderURL = defaultProviderURL + } + genericOidc, err := pom_oidc.New(ctx, o) + if err != nil { + return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) + } + p.Provider = genericOidc + p.UserGroupFn = p.UserGroups + return &p, nil +} + +// GetSignInURL returns the sign in url with typical oauth parameters +// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-implicit-grant-flow +func (p *Provider) GetSignInURL(state string) string { + return p.Oauth.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "select_account")) +} + +// UserGroups returns a slice of group names a given user is in. +// `Directory.Read.All` is required. +// https://docs.microsoft.com/en-us/graph/api/resources/directoryobject?view=graph-rest-1.0 +// https://docs.microsoft.com/en-us/graph/api/user-list-memberof?view=graph-rest-1.0 +func (p *Provider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { + if s == nil || s.AccessToken == nil { + return nil, errors.New("identity/azure: session cannot be nil") + } + var response struct { + Groups []struct { + ID string `json:"id"` + Description string `json:"description,omitempty"` + DisplayName string `json:"displayName"` + CreatedDateTime time.Time `json:"createdDateTime,omitempty"` + GroupTypes []string `json:"groupTypes,omitempty"` + } `json:"value"` + } + headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.AccessToken.AccessToken)} + err := httputil.Client(ctx, http.MethodGet, defaultGroupURL, version.UserAgent(), headers, nil, &response) + if err != nil { + return nil, err + } + var groups []string + for _, group := range response.Groups { + log.Debug().Str("DisplayName", group.DisplayName).Str("ID", group.ID).Msg("microsoft: group") + groups = append(groups, group.ID) + } + return groups, nil +} diff --git a/internal/identity/oidc/errors.go b/internal/identity/oidc/errors.go new file mode 100644 index 000000000..197269b3f --- /dev/null +++ b/internal/identity/oidc/errors.go @@ -0,0 +1,16 @@ +package oidc + +import "errors" + +// ErrRevokeNotImplemented error type when Revoke method is not implemented +// by an identity provider +var ErrRevokeNotImplemented = errors.New("identity/oidc: revoke not implemented") + +// ErrSignoutNotImplemented error type when end session is not implemented +// by an identity provider +// https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPInitiated +var ErrSignoutNotImplemented = errors.New("identity/oidc: end session not implemented") + +// ErrMissingProviderURL is returned when an identity provider requires a provider url +// does not receive one. +var ErrMissingProviderURL = errors.New("identity/oidc: missing provider url") diff --git a/internal/identity/oidc/gitlab/gitlab.go b/internal/identity/oidc/gitlab/gitlab.go new file mode 100644 index 000000000..b183d1b9e --- /dev/null +++ b/internal/identity/oidc/gitlab/gitlab.go @@ -0,0 +1,97 @@ +// Package gitlab implements OpenID Connect for Gitlab +// +// https://www.pomerium.io/docs/identity-providers/gitlab.html +package gitlab + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/coreos/go-oidc" + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/identity/oauth" + pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/sessions" + "github.com/pomerium/pomerium/internal/version" +) + +// Name identifies the GitLab identity provider +const Name = "gitlab" + +var defaultScopes = []string{oidc.ScopeOpenID, "api", "read_user", "profile", "email"} + +const ( + defaultProviderURL = "https://gitlab.com" + + // groupPath is the url to return a list of groups for the authenticated user + // https://docs.gitlab.com/ee/api/groups.html + groupPath = "/api/v4/groups" +) + +// Provider is a Gitlab implementation of the Authenticator interface. +type Provider struct { + *pom_oidc.Provider + + userGroupURL string +} + +// New instantiates an OpenID Connect (OIDC) provider for Gitlab. +func New(ctx context.Context, o *oauth.Options) (*Provider, error) { + var p Provider + var err error + if o.ProviderURL == "" { + o.ProviderURL = defaultProviderURL + } + if len(o.Scopes) == 0 { + o.Scopes = defaultScopes + } + genericOidc, err := pom_oidc.New(ctx, o) + if err != nil { + return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) + } + p.Provider = genericOidc + p.UserGroupFn = p.UserGroups + p.userGroupURL = o.ProviderURL + groupPath + + return &p, nil +} + +// UserGroups returns a slice of groups for the user. +// +// Returns 20 results at a time because the API results are paginated. +// https://docs.gitlab.com/ee/api/groups.html#list-groups +func (p *Provider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { + if s == nil || s.AccessToken == nil { + return nil, errors.New("gitlab: user session cannot be empty") + } + + var response []struct { + ID json.Number `json:"id"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + Description string `json:"description,omitempty"` + Visibility string `json:"visibility,omitempty"` + ShareWithGroupLock bool `json:"share_with_group_lock,omitempty"` + RequireTwoFactorAuthentication bool `json:"require_two_factor_authentication,omitempty"` + SubgroupCreationLevel string `json:"subgroup_creation_level,omitempty"` + FullName string `json:"full_name,omitempty"` + FullPath string `json:"full_path,omitempty"` + } + headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.AccessToken.AccessToken)} + err := httputil.Client(ctx, http.MethodGet, p.userGroupURL, version.UserAgent(), headers, nil, &response) + if err != nil { + return nil, err + } + + var groups []string + log.Debug().Interface("response", response).Msg("gitlab: groups") + for _, group := range response { + groups = append(groups, group.ID.String()) + } + + return groups, nil +} diff --git a/internal/identity/google.go b/internal/identity/oidc/google/google.go similarity index 55% rename from internal/identity/google.go rename to internal/identity/oidc/google/google.go index d4d738516..b2d4191e5 100644 --- a/internal/identity/google.go +++ b/internal/identity/oidc/google/google.go @@ -1,4 +1,8 @@ -package identity +// Package google implements OpenID Connect for Google and GSuite. +// +// https://www.pomerium.io/docs/identity-providers/google.html +// https://developers.google.com/identity/protocols/oauth2/openid-connect +package google import ( "context" @@ -11,63 +15,57 @@ import ( "golang.org/x/oauth2/google" admin "google.golang.org/api/admin/directory/v1" + "github.com/pomerium/pomerium/internal/identity/oauth" + pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/sessions" ) -const defaultGoogleProviderURL = "https://accounts.google.com" +const ( + // Name identifies the Google identity provider + Name = "google" -// GoogleProvider is an implementation of the Provider interface. -type GoogleProvider struct { - *Provider + defaultProviderURL = "https://accounts.google.com" +) +var defaultScopes = []string{oidc.ScopeOpenID, "profile", "email"} + +// Provider is a Google implementation of the Authenticator interface. +type Provider struct { + *pom_oidc.Provider + + // todo(bdd): we could probably save on a big ol set of imports + // by calling this API directly apiClient *admin.Service } -// NewGoogleProvider instantiates an OpenID Connect (OIDC) session with Google. -func NewGoogleProvider(p *Provider) (*GoogleProvider, error) { - ctx := context.Background() - if p.ProviderURL == "" { - p.ProviderURL = defaultGoogleProviderURL - } +// New instantiates an OpenID Connect (OIDC) session with Google. +func New(ctx context.Context, o *oauth.Options) (*Provider, error) { + var p Provider var err error - p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) + if o.ProviderURL == "" { + o.ProviderURL = defaultProviderURL + } + if len(o.Scopes) == 0 { + o.Scopes = defaultScopes + } + genericOidc, err := pom_oidc.New(ctx, o) if err != nil { - return nil, err - } - // Google rejects the offline scope favoring "access_type=offline" - // as part of the authorization request instead. - if len(p.Scopes) == 0 { - p.Scopes = []string{oidc.ScopeOpenID, "profile", "email"} - } - 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, + return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) } + p.Provider = genericOidc - gp := &GoogleProvider{ - Provider: p, - } - - // build api client to make group membership api calls - if err := p.provider.Claims(&gp); err != nil { - return nil, err - } // if service account set, configure admin sdk calls - if p.ServiceAccount != "" { - apiCreds, err := base64.StdEncoding.DecodeString(p.ServiceAccount) + if o.ServiceAccount != "" { + apiCreds, err := base64.StdEncoding.DecodeString(o.ServiceAccount) if err != nil { - return nil, fmt.Errorf("identity/google: could not decode service account json %w", err) + return nil, fmt.Errorf("google: could not decode service account json %w", err) } // Required scopes for groups api // https://developers.google.com/admin-sdk/directory/v1/reference/groups/list conf, err := google.JWTConfigFromJSON(apiCreds, admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope) if err != nil { - return nil, fmt.Errorf("identity/google: failed making jwt config from json %w", err) + return nil, fmt.Errorf("google: failed making jwt config from json %w", err) } var credentialsFile struct { ImpersonateUser string `json:"impersonate_user"` @@ -77,16 +75,16 @@ func NewGoogleProvider(p *Provider) (*GoogleProvider, error) { } conf.Subject = credentialsFile.ImpersonateUser client := conf.Client(context.TODO()) - gp.apiClient, err = admin.New(client) + p.apiClient, err = admin.New(client) if err != nil { - return nil, fmt.Errorf("identity/google: failed creating admin service %w", err) + return nil, fmt.Errorf("google: failed creating admin service %w", err) } - gp.UserGroupFn = gp.UserGroups + p.UserGroupFn = p.UserGroups } else { - log.Warn().Msg("identity/google: no service account, cannot retrieve groups") + log.Warn().Msg("google: no service account, cannot retrieve groups") } - return gp, nil + return &p, nil } // GetSignInURL returns a URL to OAuth 2.0 provider's consent page that asks for permissions for @@ -100,21 +98,21 @@ func NewGoogleProvider(p *Provider) (*GoogleProvider, error) { // cookies, re-authorization will not bring back refresh_token. A work around to this is to add // prompt=consent to the OAuth redirect URL and will always return a refresh_token. // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess -func (p *GoogleProvider) GetSignInURL(state string) string { - return p.oauth.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "select_account consent")) +func (p *Provider) GetSignInURL(state string) string { + return p.Oauth.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "select_account consent")) } // UserGroups returns a slice of group names a given user is in // NOTE: groups via Directory API is limited to 1 QPS! // https://developers.google.com/admin-sdk/directory/v1/reference/groups/list // https://developers.google.com/admin-sdk/directory/v1/limits -func (p *GoogleProvider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { +func (p *Provider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { var groups []string if p.apiClient != nil { req := p.apiClient.Groups.List().UserKey(s.Subject).MaxResults(100) resp, err := req.Do() if err != nil { - return nil, fmt.Errorf("identity/google: group api request failed %w", err) + return nil, fmt.Errorf("google: group api request failed %w", err) } for _, group := range resp.Groups { groups = append(groups, group.Email) diff --git a/internal/identity/oidc/oidc.go b/internal/identity/oidc/oidc.go new file mode 100644 index 000000000..68db63048 --- /dev/null +++ b/internal/identity/oidc/oidc.go @@ -0,0 +1,216 @@ +// Package oidc implements a generic OpenID Connect provider. +// +// https://openid.net/specs/openid-connect-core-1_0.html +package oidc + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + go_oidc "github.com/coreos/go-oidc" + "golang.org/x/oauth2" + + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/identity/oauth" + "github.com/pomerium/pomerium/internal/sessions" + "github.com/pomerium/pomerium/internal/urlutil" + "github.com/pomerium/pomerium/internal/version" +) + +// Name identifies the generic OpenID Connect provider +const Name = "oidc" + +var defaultScopes = []string{go_oidc.ScopeOpenID, "profile", "email", "offline_access"} + +// Provider provides a standard, OpenID Connect implementation +// of an authorization identity provider. +// https://openid.net/specs/openid-connect-core-1_0.html +type Provider struct { + // Provider represents an OpenID Connect server's configuration. + Provider *go_oidc.Provider + // Verifier provides verification for ID Tokens. + Verifier *go_oidc.IDTokenVerifier + // Oauth describes a typical 3-legged OAuth2 flow, with both the + // client application information and the server's endpoint URLs. + Oauth *oauth2.Config + + // UserInfoURL specifies the endpoint responsible for returning claims + // about the authenticated End-User. + // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + UserInfoURL string `json:"userinfo_endpoint,omitempty"` + + // RevocationURL is the location of the OAuth 2.0 token revocation endpoint. + // https://tools.ietf.org/html/rfc7009 + RevocationURL string `json:"revocation_endpoint,omitempty"` + + // EndSessionURL is another endpoint that can be used by other identity + // providers that doesn't implement the revocation endpoint but a logout session. + // https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPInitiated + EndSessionURL string `json:"end_session_endpoint,omitempty"` + + // UserGroupFn is, if set, used to return a slice of group IDs the + // user is a member of + UserGroupFn func(context.Context, *sessions.State) ([]string, error) +} + +// New creates a new instance of a generic OpenID Connect provider. +func New(ctx context.Context, o *oauth.Options) (*Provider, error) { + var err error + var p Provider + if o.ProviderURL == "" { + return nil, ErrMissingProviderURL + } + if len(o.Scopes) == 0 { + o.Scopes = defaultScopes + } + p.Provider, err = go_oidc.NewProvider(ctx, o.ProviderURL) + if err != nil { + return nil, fmt.Errorf("identity/oidc: could not connect to %s: %w", o.ProviderName, err) + } + + p.Verifier = p.Provider.Verifier(&go_oidc.Config{ClientID: o.ClientID}) + p.Oauth = &oauth2.Config{ + ClientID: o.ClientID, + ClientSecret: o.ClientSecret, + Scopes: o.Scopes, + Endpoint: p.Provider.Endpoint(), + RedirectURL: o.RedirectURL.String(), + } + + // add non-standard claims like end-session, revoke, and user info + if err := p.Provider.Claims(&p); err != nil { + return nil, fmt.Errorf("identity/oidc: could not retrieve additional claims: %w", err) + } + return &p, nil +} + +// GetSignInURL returns the url of the provider's OAuth 2.0 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 *Provider) GetSignInURL(state string) string { + return p.Oauth.AuthCodeURL(state, oauth2.AccessTypeOffline) +} + +// Authenticate converts an authorization code returned from the identity +// provider into a token which is then converted into a user session. +func (p *Provider) Authenticate(ctx context.Context, code string) (*sessions.State, error) { + oauth2Token, err := p.Oauth.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("identity/oidc: token exchange failed: %w", err) + } + idToken, err := p.IdentityFromToken(ctx, oauth2Token) + if err != nil { + return nil, fmt.Errorf("identity/oidc: failed getting id_token: %w", err) + } + + aud, err := urlutil.ParseAndValidateURL(p.Oauth.RedirectURL) + if err != nil { + return nil, fmt.Errorf("identity/oidc: bad redirect uri: %w", err) + } + + s, err := sessions.NewStateFromTokens(idToken, oauth2Token, aud.Hostname()) + if err != nil { + return nil, err + } + + if err := p.Provider.Claims(&p); err == nil && p.UserInfoURL != "" { + userInfo, err := p.Provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) + if err != nil { + return nil, fmt.Errorf("identity/oidc: could not retrieve user info %w", err) + } + if err := userInfo.Claims(&s); err != nil { + return nil, fmt.Errorf("identity/oidc: could not parse user claims %w", err) + } + } + + if p.UserGroupFn != nil { + s.Groups, err = p.UserGroupFn(ctx, s) + if err != nil { + return nil, fmt.Errorf("internal/oidc: could not retrieve groups %w", err) + } + } + return s, nil +} + +// Refresh renews a user's session using an oidc refresh token without reprompting the user. +// Group membership is also refreshed. +// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens +func (p *Provider) Refresh(ctx context.Context, s *sessions.State) (*sessions.State, error) { + if s.AccessToken == nil || s.AccessToken.RefreshToken == "" { + return nil, errors.New("internal/oidc: missing refresh token") + } + + t := oauth2.Token{RefreshToken: s.AccessToken.RefreshToken} + oauthToken, err := p.Oauth.TokenSource(ctx, &t).Token() + if err != nil { + return nil, fmt.Errorf("internal/oidc: refresh failed %w", err) + } + idToken, err := p.IdentityFromToken(ctx, oauthToken) + if err != nil { + return nil, fmt.Errorf("identity/oidc: failed getting id_token: %w", err) + } + if err := s.UpdateState(idToken, oauthToken); err != nil { + return nil, fmt.Errorf("internal/oidc: state update failed %w", err) + } + if p.UserGroupFn != nil { + s.Groups, err = p.UserGroupFn(ctx, s) + if err != nil { + return nil, fmt.Errorf("internal/oidc: could not retrieve groups %w", err) + } + } + return s, nil +} + +// IdentityFromToken takes an identity provider issued JWT as input ('id_token') +// and returns a session state. The provided token's audience ('aud') must +// match Pomerium's client_id. +func (p *Provider) IdentityFromToken(ctx context.Context, t *oauth2.Token) (*go_oidc.IDToken, error) { + rawIDToken, ok := t.Extra("id_token").(string) + if !ok { + return nil, fmt.Errorf("internal/oidc: id_token not found") + } + return p.Verifier.Verify(ctx, rawIDToken) +} + +// Revoke enables a user to revoke her token. If the identity provider does not +// support revocation an error is thrown. +// +// https://tools.ietf.org/html/rfc7009#section-2.1 +func (p *Provider) Revoke(ctx context.Context, token *oauth2.Token) error { + if p.RevocationURL == "" { + return ErrRevokeNotImplemented + } + + params := url.Values{} + params.Add("token", token.AccessToken) + params.Add("token_type_hint", "access_token") + // Some providers like okta / onelogin require "client authentication" + // https://developer.okta.com/docs/reference/api/oidc/#client-secret + // https://developers.onelogin.com/openid-connect/api/revoke-session + params.Add("client_id", p.Oauth.ClientID) + params.Add("client_secret", p.Oauth.ClientSecret) + + err := httputil.Client(ctx, http.MethodPost, p.RevocationURL, version.UserAgent(), nil, params, nil) + if err != nil && err != httputil.ErrTokenRevoked { + return fmt.Errorf("internal/oidc: unexpected revoke error: %w", err) + } + + return nil +} + +// LogOut returns the EndSessionURL endpoint to allow a logout +// session to be initiated. +// https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPInitiated +func (p *Provider) LogOut() (*url.URL, error) { + if p.EndSessionURL == "" { + return nil, ErrSignoutNotImplemented + } + return urlutil.ParseAndValidateURL(p.EndSessionURL) +} diff --git a/internal/identity/oidc/okta/okta.go b/internal/identity/oidc/okta/okta.go new file mode 100644 index 000000000..1c8efc4e7 --- /dev/null +++ b/internal/identity/oidc/okta/okta.go @@ -0,0 +1,88 @@ +// Package okta implements OpenID Connect for okta +// +// https://www.pomerium.io/docs/identity-providers/okta.html +package okta + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/identity/oauth" + pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/sessions" + "github.com/pomerium/pomerium/internal/urlutil" + "github.com/pomerium/pomerium/internal/version" +) + +const ( + // Name identifies the Okta identity provider + Name = "okta" + + // https://developer.okta.com/docs/reference/api/users/ + userAPIPath = "/api/v1/users/" +) + +// Provider is an Okta implementation of the Authenticator interface. +type Provider struct { + *pom_oidc.Provider + + userAPI *url.URL + + // serviceAccount is the the custom HTTP authentication used for okta + // https://developer.okta.com/docs/reference/api-overview/#authentication + serviceAccount string +} + +// New instantiates an OpenID Connect (OIDC) provider for Okta. +func New(ctx context.Context, o *oauth.Options) (*Provider, error) { + var p Provider + var err error + genericOidc, err := pom_oidc.New(ctx, o) + if err != nil { + return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) + } + p.Provider = genericOidc + + if o.ServiceAccount != "" { + userAPI, err := urlutil.ParseAndValidateURL(o.ProviderURL) + if err != nil { + return nil, err + } + p.userAPI = userAPI + p.userAPI.Path = userAPIPath + p.serviceAccount = o.ServiceAccount + p.UserGroupFn = p.UserGroups + } else { + log.Warn().Msg("okta: api token not set, cannot retrieve groups") + } + return &p, nil +} + +// UserGroups fetches the groups of which the user is a member +// https://developer.okta.com/docs/reference/api/users/#get-user-s-groups +func (p *Provider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { + var response []struct { + ID string `json:"id"` + Profile struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"profile"` + } + + headers := map[string]string{"Authorization": fmt.Sprintf("SSWS %s", p.serviceAccount)} + uri := fmt.Sprintf("%s/%s/groups", p.userAPI.String(), s.Subject) + err := httputil.Client(ctx, http.MethodGet, uri, version.UserAgent(), headers, nil, &response) + if err != nil { + return nil, err + } + var groups []string + for _, group := range response { + log.Debug().Interface("group", group).Msg("okta: group") + groups = append(groups, group.ID) + } + return groups, nil +} diff --git a/internal/identity/oidc/onelogin/onelogin.go b/internal/identity/oidc/onelogin/onelogin.go new file mode 100644 index 000000000..bccd13d5e --- /dev/null +++ b/internal/identity/oidc/onelogin/onelogin.go @@ -0,0 +1,78 @@ +// Package onelogin implements OpenID Connect for OneLogin +// +// https://www.pomerium.io/docs/identity-providers/one-login.html +package onelogin + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + oidc "github.com/coreos/go-oidc" + + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/identity/oauth" + pom_oidc "github.com/pomerium/pomerium/internal/identity/oidc" + "github.com/pomerium/pomerium/internal/sessions" + "github.com/pomerium/pomerium/internal/version" +) + +const ( + // Name identifies the OneLogin identity provider + Name = "onelogin" + + defaultProviderURL = "https://openid-connect.onelogin.com/oidc" + defaultOneloginGroupURL = "https://openid-connect.onelogin.com/oidc/me" +) + +var defaultScopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", "offline_access"} + +// Provider is an OneLogin implementation of the Authenticator interface. +type Provider struct { + *pom_oidc.Provider +} + +// New instantiates an OpenID Connect (OIDC) provider for OneLogin. +func New(ctx context.Context, o *oauth.Options) (*Provider, error) { + var p Provider + var err error + if o.ProviderURL == "" { + o.ProviderURL = defaultProviderURL + } + if len(o.Scopes) == 0 { + o.Scopes = defaultScopes + } + genericOidc, err := pom_oidc.New(ctx, o) + if err != nil { + return nil, fmt.Errorf("%s: failed creating oidc provider: %w", Name, err) + } + p.Provider = genericOidc + p.UserGroupFn = p.UserGroups + return &p, nil +} + +// UserGroups returns a slice of group names a given user is in. +// https://developers.onelogin.com/openid-connect/api/user-info +func (p *Provider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { + if s == nil || s.AccessToken == nil { + return nil, errors.New("identity/onelogin: session cannot be nil") + } + var response struct { + User string `json:"sub"` + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + UpdatedAt time.Time `json:"updated_at"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Groups []string `json:"groups"` + } + headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.AccessToken.AccessToken)} + err := httputil.Client(ctx, http.MethodGet, defaultOneloginGroupURL, version.UserAgent(), headers, nil, &response) + if err != nil { + return nil, err + } + return response.Groups, nil +} diff --git a/internal/identity/okta.go b/internal/identity/okta.go deleted file mode 100644 index 7c242b25c..000000000 --- a/internal/identity/okta.go +++ /dev/null @@ -1,93 +0,0 @@ -package identity - -import ( - "context" - "fmt" - "net/http" - "net/url" - - oidc "github.com/coreos/go-oidc" - "golang.org/x/oauth2" - - "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/sessions" - "github.com/pomerium/pomerium/internal/urlutil" - "github.com/pomerium/pomerium/internal/version" -) - -// OktaProvider represents the Okta Identity Provider -// -// https://www.pomerium.io/docs/identity-providers.html#okta -type OktaProvider struct { - *Provider - - userAPI *url.URL -} - -// NewOktaProvider creates a new instance of Okta as an identity provider. -func NewOktaProvider(p *Provider) (*OktaProvider, error) { - ctx := context.Background() - if p.ProviderURL == "" { - return nil, ErrMissingProviderURL - } - var err error - p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) - if err != nil { - return nil, err - } - if len(p.Scopes) == 0 { - p.Scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", "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: p.Scopes, - } - - oktaProvider := OktaProvider{Provider: p} - if err := p.provider.Claims(&oktaProvider); err != nil { - return nil, err - } - - if p.ServiceAccount != "" { - p.UserGroupFn = oktaProvider.UserGroups - userAPI, err := urlutil.ParseAndValidateURL(p.ProviderURL) - if err != nil { - return nil, err - } - userAPI.Path = "/api/v1/users/" - oktaProvider.userAPI = userAPI - } else { - log.Warn().Msg("identity/okta: api token not set, cannot retrieve groups") - } - - return &oktaProvider, nil -} - -// UserGroups fetches the groups of which the user is a member -// https://developer.okta.com/docs/reference/api/users/#get-user-s-groups -func (p *OktaProvider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { - var response []struct { - ID string `json:"id"` - Profile struct { - Name string `json:"name"` - Description string `json:"description"` - } `json:"profile"` - } - - headers := map[string]string{"Authorization": fmt.Sprintf("SSWS %s", p.ServiceAccount)} - err := httputil.Client(ctx, http.MethodGet, fmt.Sprintf("%s/%s/groups", p.userAPI.String(), s.Subject), version.UserAgent(), headers, nil, &response) - if err != nil { - return nil, err - } - var groups []string - for _, group := range response { - log.Debug().Interface("group", group).Msg("identity/okta: group") - groups = append(groups, group.ID) - } - return groups, nil -} diff --git a/internal/identity/onelogin.go b/internal/identity/onelogin.go deleted file mode 100644 index 40469e697..000000000 --- a/internal/identity/onelogin.go +++ /dev/null @@ -1,83 +0,0 @@ -package identity - -import ( - "context" - "errors" - "fmt" - "net/http" - "time" - - oidc "github.com/coreos/go-oidc" - "golang.org/x/oauth2" - - "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/sessions" - "github.com/pomerium/pomerium/internal/version" -) - -const defaultOneLoginProviderURL = "https://openid-connect.onelogin.com/oidc" -const defaultOneloginGroupURL = "https://openid-connect.onelogin.com/oidc/me" - -// OneLoginProvider provides a standard, OpenID Connect implementation -// of an authorization identity provider. -type OneLoginProvider struct { - *Provider -} - -// NewOneLoginProvider creates a new instance of an OpenID Connect provider. -func NewOneLoginProvider(p *Provider) (*OneLoginProvider, error) { - ctx := context.Background() - if p.ProviderURL == "" { - p.ProviderURL = defaultOneLoginProviderURL - } - var err error - p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) - if err != nil { - return nil, err - } - if len(p.Scopes) == 0 { - p.Scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", "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: p.Scopes, - } - - olProvider := OneLoginProvider{Provider: p} - - if err := p.provider.Claims(&olProvider); err != nil { - return nil, err - } - - p.UserGroupFn = olProvider.UserGroups - - return &olProvider, nil -} - -// UserGroups returns a slice of group names a given user is in. -// https://developers.onelogin.com/openid-connect/api/user-info -func (p *OneLoginProvider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { - if s == nil || s.AccessToken == nil { - return nil, errors.New("identity/onelogin: session cannot be nil") - } - var response struct { - User string `json:"sub"` - Email string `json:"email"` - PreferredUsername string `json:"preferred_username"` - Name string `json:"name"` - UpdatedAt time.Time `json:"updated_at"` - GivenName string `json:"given_name"` - FamilyName string `json:"family_name"` - Groups []string `json:"groups"` - } - headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.AccessToken.AccessToken)} - err := httputil.Client(ctx, http.MethodGet, defaultOneloginGroupURL, version.UserAgent(), headers, nil, &response) - if err != nil { - return nil, err - } - return response.Groups, nil -} diff --git a/internal/identity/providers.go b/internal/identity/providers.go index 32fb7e63f..e375d8be7 100644 --- a/internal/identity/providers.go +++ b/internal/identity/providers.go @@ -4,41 +4,34 @@ package identity import ( "context" - "errors" "fmt" - "net/http" "net/url" - "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/sessions" - "github.com/pomerium/pomerium/internal/urlutil" - "github.com/pomerium/pomerium/internal/version" - - oidc "github.com/coreos/go-oidc" "golang.org/x/oauth2" + + "github.com/pomerium/pomerium/internal/identity/oauth" + "github.com/pomerium/pomerium/internal/identity/oauth/github" + "github.com/pomerium/pomerium/internal/identity/oidc" + "github.com/pomerium/pomerium/internal/identity/oidc/azure" + "github.com/pomerium/pomerium/internal/identity/oidc/gitlab" + "github.com/pomerium/pomerium/internal/identity/oidc/google" + "github.com/pomerium/pomerium/internal/identity/oidc/okta" + "github.com/pomerium/pomerium/internal/identity/oidc/onelogin" + "github.com/pomerium/pomerium/internal/sessions" ) -const ( - // AzureProviderName identifies the Azure identity provider - AzureProviderName = "azure" - // GitlabProviderName identifies the GitLab identity provider - GitlabProviderName = "gitlab" - // GithubProviderName identifies the GitHub identity provider - GithubProviderName = "github" - // GoogleProviderName identifies the Google identity provider - GoogleProviderName = "google" - // OIDCProviderName identifies a generic OpenID connect provider - OIDCProviderName = "oidc" - // OktaProviderName identifies the Okta identity provider - OktaProviderName = "okta" - // OneLoginProviderName identifies the OneLogin identity provider - OneLoginProviderName = "onelogin" +var ( + // compile time assertions that providers are satisfying the interface + _ Authenticator = &azure.Provider{} + _ Authenticator = &gitlab.Provider{} + _ Authenticator = &github.Provider{} + _ Authenticator = &google.Provider{} + _ Authenticator = &oidc.Provider{} + _ Authenticator = &okta.Provider{} + _ Authenticator = &onelogin.Provider{} + _ Authenticator = &MockProvider{} ) -// ErrMissingProviderURL is returned when an identity provider requires a provider url -// does not receive one. -var ErrMissingProviderURL = errors.New("internal/identity: missing provider url") - // Authenticator is an interface representing the ability to authenticate with an identity provider. type Authenticator interface { Authenticate(context.Context, string) (*sessions.State, error) @@ -48,195 +41,29 @@ type Authenticator interface { LogOut() (*url.URL, error) } -// New returns a new identity provider based on its name. -// Returns an error if selected provided not found or if the identity provider is not known. -func New(providerName string, p *Provider) (a Authenticator, err error) { - switch providerName { - case AzureProviderName: - a, err = NewAzureProvider(p) - case GitlabProviderName: - a, err = NewGitLabProvider(p) - case GithubProviderName: - a, err = NewGitHubProvider(p) - case GoogleProviderName: - a, err = NewGoogleProvider(p) - case OIDCProviderName: - a, err = NewOIDCProvider(p) - case OktaProviderName: - a, err = NewOktaProvider(p) - case OneLoginProviderName: - a, err = NewOneLoginProvider(p) +// NewAuthenticator returns a new identity provider based on its name. +func NewAuthenticator(o oauth.Options) (a Authenticator, err error) { + ctx := context.Background() + switch o.ProviderName { + case azure.Name: + a, err = azure.New(ctx, &o) + case gitlab.Name: + a, err = gitlab.New(ctx, &o) + case github.Name: + a, err = github.New(ctx, &o) + case google.Name: + a, err = google.New(ctx, &o) + case oidc.Name: + a, err = oidc.New(ctx, &o) + case okta.Name: + a, err = okta.New(ctx, &o) + case onelogin.Name: + a, err = onelogin.New(ctx, &o) default: - return nil, fmt.Errorf("internal/identity: %s provider not known", providerName) + return nil, fmt.Errorf("identity: unknown provider: %s", o.ProviderName) } if err != nil { return nil, err } return a, nil } - -// Provider 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 Provider struct { - ProviderName string - - RedirectURL *url.URL - - ClientID string - ClientSecret string - ProviderURL string - Scopes []string - - UserGroupFn func(context.Context, *sessions.State) ([]string, error) - - UserInfoEndpoint bool - - // ServiceAccount can be set for those providers that require additional - // credentials or tokens to do follow up API calls (e.g. Google) - ServiceAccount string - - provider *oidc.Provider - verifier *oidc.IDTokenVerifier - oauth *oauth2.Config - - // We will attempt to get the identity provider's possible information from - // their /.well-known/openid-configuration. - // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo - UserInfoURL string `json:"userinfo_endpoint"` - - // RevocationURL is the location of the OAuth 2.0 token revocation endpoint. - // https://tools.ietf.org/html/rfc7009 - RevocationURL string `json:"revocation_endpoint,omitempty"` - - // EndSessionURL is another endpoint that can be used by other identity - // providers that doesn't implement the revocation endpoint but a logout session. - // https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPInitiated - // e.g Microsoft Azure - // https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration - EndSessionURL string `json:"end_session_endpoint,omitempty"` -} - -// 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 *Provider) GetSignInURL(state string) string { - return p.oauth.AuthCodeURL(state, oauth2.AccessTypeOffline) -} - -// Authenticate creates an identity session with google from a authorization code, and follows up -// call to the admin/group api to check what groups the user is in. -func (p *Provider) Authenticate(ctx context.Context, code string) (*sessions.State, error) { - oauth2Token, err := p.oauth.Exchange(ctx, code) - if err != nil { - return nil, fmt.Errorf("internal/identity: token exchange failed: %w", err) - } - idToken, err := p.IdentityFromToken(ctx, oauth2Token) - if err != nil { - return nil, err - } - - s, err := sessions.NewStateFromTokens(idToken, oauth2Token, p.RedirectURL.Host) - if err != nil { - return nil, err - } - - if err := p.provider.Claims(&p); err == nil && p.UserInfoURL != "" { - userInfo, err := p.provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) - if err != nil { - return nil, fmt.Errorf("internal/identity: could not retrieve user info %w", err) - } - if err := userInfo.Claims(&s); err != nil { - return nil, err - } - } - - if p.UserGroupFn != nil { - s.Groups, err = p.UserGroupFn(ctx, s) - if err != nil { - return nil, fmt.Errorf("internal/identity: could not retrieve groups %w", err) - } - } - return s, nil -} - -// Refresh renews a user's session using an oidc refresh token without reprompting the user. -// Group membership is also refreshed. -// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens -func (p *Provider) Refresh(ctx context.Context, s *sessions.State) (*sessions.State, error) { - if s.AccessToken == nil || s.AccessToken.RefreshToken == "" { - return nil, errors.New("internal/identity: missing refresh token") - } - - t := oauth2.Token{RefreshToken: s.AccessToken.RefreshToken} - oauthToken, err := p.oauth.TokenSource(ctx, &t).Token() - if err != nil { - return nil, fmt.Errorf("internal/identity: refresh failed %w", err) - } - idToken, err := p.IdentityFromToken(ctx, oauthToken) - if err != nil { - return nil, err - } - if err := s.UpdateState(idToken, oauthToken); err != nil { - return nil, fmt.Errorf("internal/identity: state update failed %w", err) - } - if p.UserGroupFn != nil { - s.Groups, err = p.UserGroupFn(ctx, s) - if err != nil { - return nil, fmt.Errorf("internal/identity: could not retrieve groups %w", err) - } - } - return s, nil -} - -// IdentityFromToken takes an identity provider issued JWT as input ('id_token') -// and returns a session state. The provided token's audience ('aud') must -// match Pomerium's client_id. -func (p *Provider) IdentityFromToken(ctx context.Context, t *oauth2.Token) (*oidc.IDToken, error) { - rawIDToken, ok := t.Extra("id_token").(string) - if !ok { - return nil, fmt.Errorf("internal/identity: id_token not found") - } - return p.verifier.Verify(ctx, rawIDToken) -} - -// Revoke enables a user to revoke her token. If the identity provider does not -// support revocation an error is thrown. -// -// https://tools.ietf.org/html/rfc7009 -func (p *Provider) Revoke(ctx context.Context, token *oauth2.Token) error { - if p.RevocationURL == "" { - return ErrRevokeNotImplemented - } - - params := url.Values{} - // https://tools.ietf.org/html/rfc7009#section-2.1 - params.Add("token", token.AccessToken) - params.Add("token_type_hint", "access_token") - // Some providers like okta / onelogin require "client authentication" - // https://developer.okta.com/docs/reference/api/oidc/#client-secret - // https://developers.onelogin.com/openid-connect/api/revoke-session - params.Add("client_id", p.ClientID) - params.Add("client_secret", p.ClientSecret) - - err := httputil.Client(ctx, http.MethodPost, p.RevocationURL, version.UserAgent(), nil, params, nil) - if err != nil && err != httputil.ErrTokenRevoked { - return err - } - - return nil -} - -// LogOut returns the EndSessionURL endpoint to allow a logout -// session to be initiated. -// https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPInitiated -func (p *Provider) LogOut() (*url.URL, error) { - if p.EndSessionURL == "" { - return nil, ErrSignoutNotImplemented - } - return urlutil.ParseAndValidateURL(p.EndSessionURL) -}