identity: abstract identity providers by type (#560)

Signed-off-by: Bobby DeSimone <bobbydesimone@gmail.com>
This commit is contained in:
Bobby DeSimone 2020-04-23 10:36:24 -07:00 committed by GitHub
parent f4868dd4dd
commit 627a591824
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 773 additions and 746 deletions

View file

@ -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`.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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