identity/provider: implement generic revoke method (#595)

Co-authored-by: Bobby DeSimone <bobbydesimone@gmail.com>
This commit is contained in:
Ogundele Olumide 2020-04-21 22:40:33 +01:00 committed by GitHub
parent 45c706666c
commit 75f4dadad6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 141 additions and 133 deletions

View file

@ -217,6 +217,9 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) error {
// SignOut signs the user out and attempts to revoke the user's identity session
// Handles both GET and POST.
func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error {
// no matter what happens, we want to clear the local session store
defer a.sessionStore.ClearSession(w, r)
jwt, err := sessions.FromContext(r.Context())
if err != nil {
return httputil.NewError(http.StatusBadRequest, err)
@ -226,17 +229,28 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error {
return httputil.NewError(http.StatusBadRequest, err)
}
a.sessionStore.ClearSession(w, r)
redirectString := r.FormValue(urlutil.QueryRedirectURI)
// first, try to revoke the session if implemented
err = a.provider.Revoke(r.Context(), s.AccessToken)
if errors.Is(err, identity.ErrRevokeNotImplemented) {
log.FromRequest(r).Warn().Err(err).Msg("authenticate: revoke not implemented")
} else if err != nil {
if err != nil && !errors.Is(err, identity.ErrRevokeNotImplemented) {
return httputil.NewError(http.StatusBadRequest, err)
}
redirectURL, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryRedirectURI))
// next, try to build a logout url if implemented
endSessionURL, err := a.provider.LogOut()
if err == nil {
params := url.Values{}
params.Add("post_logout_redirect_uri", redirectString)
endSessionURL.RawQuery = params.Encode()
redirectString = endSessionURL.String()
} else if !errors.Is(err, identity.ErrSignoutNotImplemented) {
return httputil.NewError(http.StatusBadRequest, err)
}
redirectURL, err := urlutil.ParseAndValidateURL(redirectString)
if err != nil {
return httputil.NewError(http.StatusBadRequest, err)
}
httputil.Redirect(w, r, redirectURL.String(), http.StatusFound)
return nil

View file

@ -189,10 +189,10 @@ func TestAuthenticate_SignOut(t *testing.T) {
wantCode int
wantBody string
}{
{"good post", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{}, &mstore.Store{Encrypted: true, Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusFound, ""},
{"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{}, &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: 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"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -64,7 +64,7 @@ type Policy struct {
TLSCustomCAFile string `mapstructure:"tls_custom_ca_file" yaml:"tls_custom_ca_file,omitempty"`
RootCAs *x509.CertPool `yaml:",omitempty"`
// Contains the x.509 client certificate to to present to the downstream
// Contains the x.509 client certificate to present to the downstream
// host.
TLSClientCert string `mapstructure:"tls_client_cert" yaml:"tls_client_cert,omitempty"`
TLSClientKey string `mapstructure:"tls_client_key" yaml:"tls_client_key,omitempty"`

View file

@ -5,3 +5,8 @@ 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

@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/pomerium/pomerium/internal/httputil"
@ -19,11 +21,11 @@ import (
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"
githubAPIURL = "https://api.github.com"
userPath = "/user"
teamPath = "/user/teams"
revokePath = "/applications/%s/grant"
emailPath = "/user/emails"
// since github doesn't implement oidc, we need this to refresh the user session
refreshDeadline = time.Minute * 60
@ -33,22 +35,11 @@ const (
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
}
@ -61,13 +52,16 @@ func NewGitHubProvider(p *Provider) (*GitHubProvider, error) {
ClientID: p.ClientID,
ClientSecret: p.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: gp.authURL,
TokenURL: gp.tokenURL,
AuthURL: p.ProviderURL + "/login/oauth/authorize",
TokenURL: p.ProviderURL + "/login/oauth/access_token",
},
RedirectURL: p.RedirectURL.String(),
Scopes: p.Scopes,
}
gp.Provider = p
gp := &GitHubProvider{
Provider: p,
userEndpoint: githubAPIURL + userPath,
}
return gp, nil
}
@ -107,7 +101,7 @@ func (p *GitHubProvider) updateSessionState(ctx context.Context, s *sessions.Sta
err := p.userInfo(ctx, accessToken, s)
if err != nil {
return fmt.Errorf("identity/github: could not user info %w", err)
return fmt.Errorf("identity/github: could not retrieve user info %w", err)
}
err = p.userEmail(ctx, accessToken, s)
@ -152,7 +146,8 @@ func (p *GitHubProvider) userTeams(ctx context.Context, at string, s *sessions.S
}
headers := map[string]string{"Authorization": fmt.Sprintf("token %s", at)}
err := httputil.Client(ctx, http.MethodGet, githubUserTeamURL, version.UserAgent(), headers, nil, &response)
teamURL := githubAPIURL + teamPath
err := httputil.Client(ctx, http.MethodGet, teamURL, version.UserAgent(), headers, nil, &response)
if err != nil {
return err
}
@ -182,7 +177,8 @@ func (p *GitHubProvider) userEmail(ctx context.Context, at string, s *sessions.S
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)
emailURL := githubAPIURL + emailPath
err := httputil.Client(ctx, http.MethodGet, emailURL, version.UserAgent(), headers, nil, &response)
if err != nil {
return err
}
@ -223,3 +219,33 @@ func (p *GitHubProvider) userInfo(ctx context.Context, at string, s *sessions.St
s.Expiry = jwt.NewNumericDate(time.Now().Add(refreshDeadline))
return nil
}
// Revoke method will remove all the github grants the user
// 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 {
// build the basic authentication request
basicAuth := url.UserPassword(p.ClientID, p.ClientSecret)
revokeURL := url.URL{
Scheme: "https",
User: basicAuth,
Host: "api.github.com",
Path: fmt.Sprintf(revokePath, p.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")
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}

View file

@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
oidc "github.com/coreos/go-oidc"
"golang.org/x/oauth2"
@ -25,7 +24,6 @@ const (
// GitLabProvider is an implementation of the OAuth Provider
type GitLabProvider struct {
*Provider
RevokeURL string `json:"revocation_endpoint"`
}
// NewGitLabProvider returns a new GitLabProvider.
@ -55,9 +53,7 @@ func NewGitLabProvider(p *Provider) (*GitLabProvider, error) {
RedirectURL: p.RedirectURL.String(),
Scopes: p.Scopes,
}
gp := &GitLabProvider{
Provider: p,
}
gp := &GitLabProvider{Provider: p}
if err := p.provider.Claims(&gp); err != nil {
return nil, err
@ -103,17 +99,3 @@ func (p *GitLabProvider) UserGroups(ctx context.Context, s *sessions.State) ([]s
return groups, nil
}
// Revoke attempts to revoke session access via revocation endpoint
// https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#revoking-a-personal-access-token
func (p *GitLabProvider) Revoke(ctx context.Context, token *oauth2.Token) error {
params := url.Values{}
params.Add("access_token", token.AccessToken)
err := httputil.Client(ctx, http.MethodPost, p.RevokeURL, version.UserAgent(), nil, params, nil)
if err != nil && err != httputil.ErrTokenRevoked {
return err
}
return nil
}

View file

@ -5,18 +5,14 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
oidc "github.com/coreos/go-oidc"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
admin "google.golang.org/api/admin/directory/v1"
"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 defaultGoogleProviderURL = "https://accounts.google.com"
@ -25,8 +21,6 @@ const defaultGoogleProviderURL = "https://accounts.google.com"
type GoogleProvider struct {
*Provider
RevokeURL string `json:"revocation_endpoint"`
apiClient *admin.Service
}
@ -95,19 +89,6 @@ func NewGoogleProvider(p *Provider) (*GoogleProvider, error) {
return gp, nil
}
// Revoke revokes the access token a given session state.
//
// https://developers.google.com/identity/protocols/OAuth2WebServer#tokenrevoke
func (p *GoogleProvider) Revoke(ctx context.Context, token *oauth2.Token) error {
params := url.Values{}
params.Add("token", token.AccessToken)
err := httputil.Client(ctx, http.MethodPost, p.RevokeURL, version.UserAgent(), nil, params, nil)
if err != nil && err != httputil.ErrTokenRevoked {
return err
}
return nil
}
// GetSignInURL returns a URL to OAuth 2.0 provider's consent page that asks for permissions for
// the required scopes explicitly.
// Google requires an additional access scope for offline access which is a requirement for any

View file

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
oidc "github.com/coreos/go-oidc"
@ -26,8 +25,6 @@ const defaultAzureGroupURL = "https://graph.microsoft.com/v1.0/me/memberOf"
// AzureProvider is an implementation of the Provider interface
type AzureProvider struct {
*Provider
// non-standard oidc fields
RevokeURL string `json:"end_session_endpoint"`
}
// NewAzureProvider returns a new AzureProvider and sets the provider url endpoints.
@ -64,18 +61,6 @@ func NewAzureProvider(p *Provider) (*AzureProvider, error) {
return azureProvider, nil
}
// Revoke revokes the access token a given session state.
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request
func (p *AzureProvider) Revoke(ctx context.Context, token *oauth2.Token) error {
params := url.Values{}
params.Add("token", token.AccessToken)
err := httputil.Client(ctx, http.MethodPost, p.RevokeURL, version.UserAgent(), nil, params, nil)
if err != nil && err != httputil.ErrTokenRevoked {
return err
}
return 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"))

View file

@ -2,6 +2,7 @@ package identity
import (
"context"
"net/url"
"golang.org/x/oauth2"
@ -16,6 +17,8 @@ type MockProvider struct {
RefreshError error
RevokeError error
GetSignInURLResponse string
LogOutResponse url.URL
LogOutError error
}
// Authenticate is a mocked providers function.
@ -35,3 +38,6 @@ func (mp MockProvider) Revoke(ctx context.Context, s *oauth2.Token) error {
// GetSignInURL is a mocked providers function.
func (mp MockProvider) GetSignInURL(s string) string { return mp.GetSignInURLResponse }
// LogOut is a mocked providers function.
func (mp MockProvider) LogOut() (*url.URL, error) { return &mp.LogOutResponse, mp.LogOutError }

View file

@ -22,7 +22,6 @@ import (
type OktaProvider struct {
*Provider
RevokeURL string `json:"revocation_endpoint"`
userAPI *url.URL
}
@ -49,7 +48,6 @@ func NewOktaProvider(p *Provider) (*OktaProvider, error) {
Scopes: p.Scopes,
}
// okta supports a revocation endpoint
oktaProvider := OktaProvider{Provider: p}
if err := p.provider.Claims(&oktaProvider); err != nil {
return nil, err
@ -70,21 +68,6 @@ func NewOktaProvider(p *Provider) (*OktaProvider, error) {
return &oktaProvider, nil
}
// Revoke revokes the access token a given session state.
// https://developer.okta.com/docs/api/resources/oidc#revoke
func (p *OktaProvider) Revoke(ctx context.Context, token *oauth2.Token) error {
params := url.Values{}
params.Add("client_id", p.ClientID)
params.Add("client_secret", p.ClientSecret)
params.Add("token", token.AccessToken)
params.Add("token_type_hint", "refresh_token")
err := httputil.Client(ctx, http.MethodPost, p.RevokeURL, version.UserAgent(), nil, params, nil)
if err != nil && err != httputil.ErrTokenRevoked {
return err
}
return 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) {

View file

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
oidc "github.com/coreos/go-oidc"
@ -23,9 +22,6 @@ const defaultOneloginGroupURL = "https://openid-connect.onelogin.com/oidc/me"
// of an authorization identity provider.
type OneLoginProvider struct {
*Provider
// non-standard oidc fields
RevokeURL string `json:"revocation_endpoint"`
}
// NewOneLoginProvider creates a new instance of an OpenID Connect provider.
@ -62,21 +58,6 @@ func NewOneLoginProvider(p *Provider) (*OneLoginProvider, error) {
return &olProvider, nil
}
// Revoke revokes the access token a given session state.
// https://developers.onelogin.com/openid-connect/api/revoke-session
func (p *OneLoginProvider) Revoke(ctx context.Context, token *oauth2.Token) error {
params := url.Values{}
params.Add("client_id", p.ClientID)
params.Add("client_secret", p.ClientSecret)
params.Add("token", token.AccessToken)
params.Add("token_type_hint", "access_token")
err := httputil.Client(ctx, http.MethodPost, p.RevokeURL, version.UserAgent(), nil, params, nil)
if err != nil && err != httputil.ErrTokenRevoked {
return fmt.Errorf("identity/onelogin: revocation error %w", err)
}
return 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) {

View file

@ -6,9 +6,13 @@ 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"
@ -41,6 +45,7 @@ type Authenticator interface {
Refresh(context.Context, *sessions.State) (*sessions.State, error)
Revoke(context.Context, *oauth2.Token) error
GetSignInURL(state string) string
LogOut() (*url.URL, error)
}
// New returns a new identity provider based on its name.
@ -94,6 +99,22 @@ type Provider struct {
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
@ -124,14 +145,7 @@ func (p *Provider) Authenticate(ctx context.Context, code string) (*sessions.Sta
return nil, err
}
// check if provider has info endpoint, try to hit that and gather more info
// especially useful if initial request did not an contain email, or subject
// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
var claims struct {
UserInfoURL string `json:"userinfo_endpoint"`
}
if err := p.provider.Claims(&claims); err == nil && claims.UserInfoURL != "" {
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)
@ -190,8 +204,39 @@ func (p *Provider) IdentityFromToken(ctx context.Context, t *oauth2.Token) (*oid
return p.verifier.Verify(ctx, rawIDToken)
}
// Revoke enables a user to revoke her token. If the identity provider supports revocation
// the endpoint is available, otherwise an error is thrown.
// 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)
}