internal/identity: implement github provider support (#582)

Co-authored-by: Bobby DeSimone <bobbydesimone@gmail.com>
This commit is contained in:
Ogundele Olumide 2020-04-10 18:48:14 +01:00 committed by GitHub
parent 789068e27a
commit ae4204d42b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 294 additions and 2 deletions

View file

@ -16,6 +16,7 @@ import (
"github.com/pomerium/csrf" "github.com/pomerium/csrf"
"github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/cryptutil"
"github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/identity"
"github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/middleware"
"github.com/pomerium/pomerium/internal/sessions" "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) a.sessionStore.ClearSession(w, r)
err = a.provider.Revoke(r.Context(), s.AccessToken) 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) return httputil.NewError(http.StatusBadRequest, err)
} }
redirectURL, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryRedirectURI)) redirectURL, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryRedirectURI))

View file

@ -88,6 +88,7 @@ module.exports = {
"identity-providers/azure", "identity-providers/azure",
"identity-providers/cognito", "identity-providers/cognito",
"identity-providers/gitlab", "identity-providers/gitlab",
"identity-providers/github",
"identity-providers/google", "identity-providers/google",
"identity-providers/okta", "identity-providers/okta",
"identity-providers/one-login", "identity-providers/one-login",

View file

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View file

@ -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")

225
internal/identity/github.go Normal file
View file

@ -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
}

View file

@ -19,6 +19,8 @@ const (
AzureProviderName = "azure" AzureProviderName = "azure"
// GitlabProviderName identifies the GitLab identity provider // GitlabProviderName identifies the GitLab identity provider
GitlabProviderName = "gitlab" GitlabProviderName = "gitlab"
// GithubProviderName identifies the GitHub identity provider
GithubProviderName = "github"
// GoogleProviderName identifies the Google identity provider // GoogleProviderName identifies the Google identity provider
GoogleProviderName = "google" GoogleProviderName = "google"
// OIDCProviderName identifies a generic OpenID connect provider // 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) a, err = NewAzureProvider(p)
case GitlabProviderName: case GitlabProviderName:
a, err = NewGitLabProvider(p) a, err = NewGitLabProvider(p)
case GithubProviderName:
a, err = NewGitHubProvider(p)
case GoogleProviderName: case GoogleProviderName:
a, err = NewGoogleProvider(p) a, err = NewGoogleProvider(p)
case OIDCProviderName: 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 // Revoke enables a user to revoke her token. If the identity provider supports revocation
// the endpoint is available, otherwise an error is thrown. // the endpoint is available, otherwise an error is thrown.
func (p *Provider) Revoke(ctx context.Context, token *oauth2.Token) error { 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
} }