diff --git a/authenticate/handlers.go b/authenticate/handlers.go index e86e24b06..7c9bb0b9d 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -16,6 +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/log" "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/sessions" @@ -227,7 +228,9 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error { a.sessionStore.ClearSession(w, r) err = a.provider.Revoke(r.Context(), s.AccessToken) - if err != nil { + if errors.Is(err, identity.ErrRevokeNotImplemented) { + log.FromRequest(r).Warn().Err(err).Msg("authenticate: revoke not implemented") + } else if err != nil { return httputil.NewError(http.StatusBadRequest, err) } redirectURL, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryRedirectURI)) diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 8e590fac8..c983b11e2 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -88,6 +88,7 @@ module.exports = { "identity-providers/azure", "identity-providers/cognito", "identity-providers/gitlab", + "identity-providers/github", "identity-providers/google", "identity-providers/okta", "identity-providers/one-login", diff --git a/docs/docs/identity-providers/github.md b/docs/docs/identity-providers/github.md new file mode 100644 index 000000000..e61412f7f --- /dev/null +++ b/docs/docs/identity-providers/github.md @@ -0,0 +1,52 @@ +--- +title: GitHub +lang: en-US +# sidebarDepth: 0 +meta: + - name: keywords + content: github oauth2 provider identity-provider +--- + +# GitHub + +## Setting up GitHub OAuth2 for your Application + +We would like you to be aware that GitHub did not implement the OpenID Connect just OAuth2 and for this reason, we have not gotten a better way to implement revocation of user access on sign out yet. + +Also, the organizations a user belongs to will be used as the groups on Pomerium dashboard. + +Log in to [Github](https://github.com/login) or create an account. + +Navigate to your profile using the avatar on the navigation bar and go to your settings. + +![GitHub settings](./img/github/github-user-profile.png) + +Click the Developers settings and create a new OAuth Application + +![GitHub OAuth2 Application creation](./img/github/github-oauth-creation.png) + +Create a new OAuth2 application by filling the field with the following parameters: + +Field | Description +--------------------------- | -------------------------------------------- +Application name | The name of your web app +Homepage URL | The homepage URL of the application to be integrated with Pomerium +Authorization callback URL | `https://${authenticate_service_url}/oauth2/callback`, authenticate_service_url from pomerium configuration + + +After the application had been created, you will have access to the credentials, the **Client ID** and **Client Secret**. + +## Pomerium Configuration + +If the setup for GitHub OAuth application has been completed, you can create your **Pomerium** configuration like the example below: + +```bash +authenticate_service_url: https://authenticate.localhost.pomerium.io +idp_provider: "github" +idp_client_id: "REDACTED" // github application ID +idp_client_secret: "REDACTED" // github application secret +``` + +Whenever a user tries to access your application integrated with Pomerium, they will be presented with a sign-on page as below: + +![GitHub Sign-on Page](./img/github/github-signon-page.png) diff --git a/docs/docs/identity-providers/img/github/github-oauth-creation.png b/docs/docs/identity-providers/img/github/github-oauth-creation.png new file mode 100644 index 000000000..1d14902ce Binary files /dev/null and b/docs/docs/identity-providers/img/github/github-oauth-creation.png differ diff --git a/docs/docs/identity-providers/img/github/github-signon-page.png b/docs/docs/identity-providers/img/github/github-signon-page.png new file mode 100644 index 000000000..460132199 Binary files /dev/null and b/docs/docs/identity-providers/img/github/github-signon-page.png differ diff --git a/docs/docs/identity-providers/img/github/github-user-profile.png b/docs/docs/identity-providers/img/github/github-user-profile.png new file mode 100644 index 000000000..a5b41d660 Binary files /dev/null and b/docs/docs/identity-providers/img/github/github-user-profile.png differ diff --git a/internal/identity/errors.go b/internal/identity/errors.go new file mode 100644 index 000000000..3ef45fe58 --- /dev/null +++ b/internal/identity/errors.go @@ -0,0 +1,7 @@ +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") diff --git a/internal/identity/github.go b/internal/identity/github.go new file mode 100644 index 000000000..7c52f041a --- /dev/null +++ b/internal/identity/github.go @@ -0,0 +1,225 @@ +package identity + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/pomerium/pomerium/internal/httputil" + "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" +) + +const ( + defaultGitHubProviderURL = "https://github.com" + githubAuthURL = "/login/oauth/authorize" + githubUserURL = "https://api.github.com/user" + githubUserTeamURL = "https://api.github.com/user/teams" + githubRevokeURL = "https://github.com/oauth/revoke" + githubUserEmailURL = "https://api.github.com/user/emails" + + // 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 + + authURL string + tokenURL string + userEndpoint string + + RevokeURL string `json:"revocation_endpoint"` +} + +// NewGitHubProvider returns a new GitHubProvider. +func NewGitHubProvider(p *Provider) (*GitHubProvider, error) { + gp := &GitHubProvider{ + authURL: defaultGitHubProviderURL + githubAuthURL, + tokenURL: defaultGitHubProviderURL + "/login/oauth/access_token", + userEndpoint: githubUserURL, + RevokeURL: githubRevokeURL, + } + + if p.ProviderURL == "" { + p.ProviderURL = defaultGitHubProviderURL + } + + if len(p.Scopes) == 0 { + p.Scopes = []string{"user:email", "read:org"} + } + + p.oauth = &oauth2.Config{ + ClientID: p.ClientID, + ClientSecret: p.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: gp.authURL, + TokenURL: gp.tokenURL, + }, + RedirectURL: p.RedirectURL.String(), + Scopes: p.Scopes, + } + gp.Provider = p + + return gp, 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) + if err != nil { + return nil, fmt.Errorf("identity/github: token exchange failed %v", err) + } + + s := &sessions.State{ + AccessToken: &oauth2.Token{ + AccessToken: resp.AccessToken, + TokenType: resp.TokenType, + }, + AccessTokenID: resp.AccessToken, + } + + err = p.updateSessionState(ctx, s) + if err != nil { + return nil, err + } + + return s, nil +} + +// 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 { + if s == nil || s.AccessToken == nil { + return errors.New("identity/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 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) + } + + err = p.userTeams(ctx, accessToken, s) + if err != nil { + return fmt.Errorf("identity/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) { + if s.AccessToken == nil { + return nil, errors.New("identity/github: missing oauth2 access token") + } + if err := p.updateSessionState(ctx, s); err != nil { + return nil, err + } + return s, nil +} + +// userTeams returns a slice of teams the user belongs by making a request +// to github API +// +// 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 { + + var response []struct { + ID json.Number `json:"id"` + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + ReposURL string `json:"repos_url,omitempty"` + Privacy string `json:"privacy,omitempty"` + } + + headers := map[string]string{"Authorization": fmt.Sprintf("token %s", at)} + err := httputil.Client(ctx, http.MethodGet, githubUserTeamURL, version.UserAgent(), headers, nil, &response) + if err != nil { + return err + } + + log.Debug().Interface("teams", response).Msg("identity/github: user teams") + + s.Groups = nil + for _, org := range response { + s.Groups = append(s.Groups, org.ID.String()) + } + + return nil +} + +// userEmail returns the primary email of the user by making +// a query to github API. +// +// 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 { + // response represents the github user email + // https://developer.github.com/v3/users/emails/#response + var response []struct { + Email string `json:"email"` + Verified bool `json:"verified"` + Primary bool `json:"primary"` + Visibility string `json:"visibility"` + } + headers := map[string]string{"Authorization": fmt.Sprintf("token %s", at)} + err := httputil.Client(ctx, http.MethodGet, githubUserEmailURL, version.UserAgent(), headers, nil, &response) + if err != nil { + return err + } + + log.Debug().Interface("emails", response).Msg("identity/github: user emails") + for _, email := range response { + if email.Primary && email.Verified { + s.Email = email.Email + s.EmailVerified = true + return nil + } + } + return nil +} + +func (p *GitHubProvider) userInfo(ctx context.Context, at string, s *sessions.State) error { + var response struct { + ID int `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url,omitempty"` + } + + headers := map[string]string{ + "Authorization": fmt.Sprintf("token %s", at), + "Accept": "application/vnd.github.v3+json", + } + err := httputil.Client(ctx, http.MethodGet, p.userEndpoint, version.UserAgent(), headers, nil, &response) + if err != nil { + return err + } + + s.User = response.Login + s.Name = response.Name + s.Picture = response.AvatarURL + // set the session expiry + s.Expiry = jwt.NewNumericDate(time.Now().Add(refreshDeadline)) + return nil +} diff --git a/internal/identity/providers.go b/internal/identity/providers.go index ff5d420df..6cf4fb29e 100644 --- a/internal/identity/providers.go +++ b/internal/identity/providers.go @@ -19,6 +19,8 @@ const ( 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 @@ -49,6 +51,8 @@ func New(providerName string, p *Provider) (a Authenticator, err error) { a, err = NewAzureProvider(p) case GitlabProviderName: a, err = NewGitLabProvider(p) + case GithubProviderName: + a, err = NewGitHubProvider(p) case GoogleProviderName: a, err = NewGoogleProvider(p) case OIDCProviderName: @@ -189,5 +193,5 @@ func (p *Provider) IdentityFromToken(ctx context.Context, t *oauth2.Token) (*oid // 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 *Provider) Revoke(ctx context.Context, token *oauth2.Token) error { - return fmt.Errorf("internal/identity: revoke not implemented by %s", p.ProviderName) + return ErrRevokeNotImplemented }