mirror of
https://github.com/pomerium/pomerium.git
synced 2025-05-10 23:57:34 +02:00
internal/identity: implement github provider support (#582)
Co-authored-by: Bobby DeSimone <bobbydesimone@gmail.com>
This commit is contained in:
parent
789068e27a
commit
ae4204d42b
9 changed files with 294 additions and 2 deletions
|
@ -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))
|
||||||
|
|
|
@ -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",
|
||||||
|
|
52
docs/docs/identity-providers/github.md
Normal file
52
docs/docs/identity-providers/github.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Click the Developers settings and create a new OAuth Application
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
Binary file not shown.
After Width: | Height: | Size: 98 KiB |
BIN
docs/docs/identity-providers/img/github/github-signon-page.png
Normal file
BIN
docs/docs/identity-providers/img/github/github-signon-page.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 134 KiB |
BIN
docs/docs/identity-providers/img/github/github-user-profile.png
Normal file
BIN
docs/docs/identity-providers/img/github/github-user-profile.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
7
internal/identity/errors.go
Normal file
7
internal/identity/errors.go
Normal 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
225
internal/identity/github.go
Normal 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
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue