Files
pinniped/internal/upstreamgithub/upstreamgithub.go
Ryan Richard 8923704f3c Finish initial github login flow
Also:
- fix github teams query: fix bug and sort/unique the results
- add IDP display name to github downstream subject
- fix error types returned by LoginFromCallback
- add trace logs to github API results
- update e2e test
- implement placeholder version of refresh for github
2024-05-22 21:21:45 -05:00

182 lines
6.3 KiB
Go

// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package upstreamgithub implements an abstraction of upstream GitHub provider interactions.
package upstreamgithub
import (
"context"
"errors"
"fmt"
"net/http"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/federationdomain/downstreamsubject"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/githubclient"
)
// ProviderConfig holds the active configuration of an upstream GitHub provider.
type ProviderConfig struct {
Name string
ResourceUID types.UID
// APIBaseURL is the url of the GitHub API, not including the path to a specific API endpoint.
// According to the GitHub docs, it should be either https://api.github.com/ for cloud
// or https://HOSTNAME/api/v3/ for Enterprise Server.
APIBaseURL string
UsernameAttribute supervisoridpv1alpha1.GitHubUsernameAttribute
GroupNameAttribute supervisoridpv1alpha1.GitHubGroupNameAttribute
// AllowedOrganizations, when empty, means to allow users from all orgs.
AllowedOrganizations []string
// HttpClient is a client that can be used to call the GitHub APIs and token endpoint.
// This client should be configured with the user-provided CA bundle and a timeout.
HttpClient *http.Client
// OAuth2Config contains ClientID, ClientSecret, Scopes, and Endpoint (which contains auth and token endpoint URLs,
// and auth style for the token endpoint).
// OAuth2Config will not be used to compute the authorize URL because the redirect back to the Supervisor's
// callback must be different per FederationDomain. It holds data that may be useful when calculating the
// authorize URL, so that data is exposed by interface methods. However, it can be used to call the token endpoint,
// for which there is no RedirectURL needed.
OAuth2Config *oauth2.Config
}
type Provider struct {
c ProviderConfig
buildGitHubClient func(httpClient *http.Client, apiBaseURL, token string) (githubclient.GitHubInterface, error)
}
var _ upstreamprovider.UpstreamGithubIdentityProviderI = &Provider{}
// New creates a Provider. The config is not a pointer to ensure that a copy of the config is created,
// making the resulting Provider use an effectively read-only configuration.
func New(config ProviderConfig) *Provider {
return &Provider{
c: config,
buildGitHubClient: githubclient.NewGitHubClient,
}
}
func (p *Provider) GetName() string {
return p.c.Name
}
func (p *Provider) GetResourceUID() types.UID {
return p.c.ResourceUID
}
func (p *Provider) GetClientID() string {
return p.c.OAuth2Config.ClientID
}
func (p *Provider) GetScopes() []string {
return p.c.OAuth2Config.Scopes
}
func (p *Provider) GetUsernameAttribute() supervisoridpv1alpha1.GitHubUsernameAttribute {
return p.c.UsernameAttribute
}
func (p *Provider) GetGroupNameAttribute() supervisoridpv1alpha1.GitHubGroupNameAttribute {
return p.c.GroupNameAttribute
}
func (p *Provider) GetAllowedOrganizations() []string {
return p.c.AllowedOrganizations
}
func (p *Provider) GetAuthorizationURL() string {
return p.c.OAuth2Config.Endpoint.AuthURL
}
func (p *Provider) ExchangeAuthcode(ctx context.Context, authcode string, redirectURI string) (string, error) {
tok, err := p.c.OAuth2Config.Exchange(
coreosoidc.ClientContext(ctx, p.c.HttpClient),
authcode,
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
)
if err != nil {
return "", fmt.Errorf("error exchanging authorization code using GitHub API: %w", err)
}
return tok.AccessToken, nil
}
// GetUser will use the provided configuration to make HTTPS calls to the GitHub API to get the identity of the
// authenticated user and to discover their org and team memberships.
// If the user's information meets the AllowedOrganization criteria specified on the GitHubIdentityProvider,
// they will be allowed to log in.
// Note that errors from the githubclient package already have helpful error prefixes, so there is no need for additional prefixes here.
func (p *Provider) GetUser(ctx context.Context, accessToken string, idpDisplayName string) (*upstreamprovider.GitHubUser, error) {
githubClient, err := p.buildGitHubClient(p.c.HttpClient, p.c.APIBaseURL, accessToken)
if err != nil {
return nil, err
}
githubUser := upstreamprovider.GitHubUser{}
userInfo, err := githubClient.GetUserInfo(ctx)
if err != nil {
return nil, err
}
githubUser.DownstreamSubject = downstreamsubject.GitHub(p.c.APIBaseURL, idpDisplayName, userInfo.Login, userInfo.ID)
switch p.c.UsernameAttribute {
case supervisoridpv1alpha1.GitHubUsernameLoginAndID:
githubUser.Username = fmt.Sprintf("%s:%s", userInfo.Login, userInfo.ID)
case supervisoridpv1alpha1.GitHubUsernameLogin:
githubUser.Username = userInfo.Login
case supervisoridpv1alpha1.GitHubUsernameID:
githubUser.Username = userInfo.ID
default:
return nil, fmt.Errorf("bad configuration: unknown GitHub username attribute: %s", p.c.UsernameAttribute)
}
orgMembership, err := githubClient.GetOrgMembership(ctx)
if err != nil {
return nil, err
}
allowedOrgs := sets.New[string](p.c.AllowedOrganizations...)
if allowedOrgs.Len() > 0 && allowedOrgs.Intersection(orgMembership).Len() < 1 {
return nil, errors.New("user is not allowed to log in due to organization membership policy")
}
teamMembership, err := githubClient.GetTeamMembership(ctx, allowedOrgs)
if err != nil {
return nil, err
}
for _, team := range teamMembership {
downstreamGroup := ""
switch p.c.GroupNameAttribute {
case supervisoridpv1alpha1.GitHubUseTeamNameForGroupName:
downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Name)
case supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName:
downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Slug)
default:
return nil, fmt.Errorf("bad configuration: unknown GitHub group name attribute: %s", p.c.GroupNameAttribute)
}
githubUser.Groups = append(githubUser.Groups, downstreamGroup)
}
return &githubUser, nil
}
// GetConfig returns the config. This is not part of the UpstreamGithubIdentityProviderI interface and is just for testing.
func (p *Provider) GetConfig() ProviderConfig {
return p.c
}