Files
teleport/lib/auth/github.go
T
Dan Upton 1e7abe0873 Support username in role templates and expressions (#64888)
Adds a new variable `user.metadata.name` that can be used in role templates:

```yaml
allow:
  node_labels:
    owner: '{{user.metadata.name}}'
```

Or in expressions:

```yaml
allow:
  node_labels_expression: |
    labels["owner"] == user.metadata.name
```

So that you can create roles that allow access to user-owned resources such as
"Connect My Computer" nodes, or the upcoming Beams feature.
2026-04-15 16:29:41 +00:00

1225 lines
39 KiB
Go

/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/gravitational/trace"
"golang.org/x/oauth2"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/constants"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/client/sso"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/loginrule"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/utils"
)
// ErrGithubNoTeams results from a github user not belonging to any teams.
var ErrGithubNoTeams = trace.BadParameter("user does not belong to any teams configured in connector; the configuration may have typos.")
// InvalidClientRedirectErrorMessage is a string added to SSO login errors
// caused by an invalid client redirect URL; the presence of this string is
// checked by the proxy to provide a more useful error message to the user when
// logging in.
const InvalidClientRedirectErrorMessage = "invalid or disallowed client redirect URL"
// GithubConverter is a thin wrapper around the [authclient.ClientI] interface that
// ensures GitHub auth connectors use the registered implementation.
type GithubConverter struct {
authclient.ClientI
}
// WithGithubConnectorConversions takes a [authclient.ClientI] and returns one that
// ensures returned or passed [types.GithubConnector] interfaces
// use the registered implementation for the following methods:
//
// - ClientI.GetGithubConnector
// - ClientI.GetGithubConnectors
// - ClientI.UpsertGithubConnector
//
// This is function is necessary so that the
// [github.com/gravitational/teleport/api] module does not import
// [github.com/gravitational/teleport/lib/services].
func WithGithubConnectorConversions(c authclient.ClientI) authclient.ClientI {
return &GithubConverter{
ClientI: c,
}
}
func (g *GithubConverter) GetGithubConnector(ctx context.Context, name string, withSecrets bool) (types.GithubConnector, error) {
connector, err := g.ClientI.GetGithubConnector(ctx, name, withSecrets)
if err != nil {
return nil, trace.Wrap(err)
}
connector, err = services.InitGithubConnector(connector)
if err != nil {
return nil, trace.Wrap(err)
}
return connector, nil
}
func (g *GithubConverter) GetGithubConnectors(ctx context.Context, withSecrets bool) ([]types.GithubConnector, error) {
connectors, err := g.ClientI.GetGithubConnectors(ctx, withSecrets)
if err != nil {
return nil, trace.Wrap(err)
}
for i, connector := range connectors {
connectors[i], err = services.InitGithubConnector(connector)
if err != nil {
return nil, trace.Wrap(err)
}
}
return connectors, nil
}
func (g *GithubConverter) UpsertGithubConnector(ctx context.Context, connector types.GithubConnector) (types.GithubConnector, error) {
convertedConnector, err := services.ConvertGithubConnector(connector)
if err != nil {
return nil, trace.Wrap(err)
}
connector, err = g.ClientI.UpsertGithubConnector(ctx, convertedConnector)
return connector, trace.Wrap(err)
}
func (g *GithubConverter) CreateGithubConnector(ctx context.Context, connector types.GithubConnector) (types.GithubConnector, error) {
convertedConnector, err := services.ConvertGithubConnector(connector)
if err != nil {
return nil, trace.Wrap(err)
}
created, err := g.ClientI.CreateGithubConnector(ctx, convertedConnector)
return created, trace.Wrap(err)
}
func (g *GithubConverter) UpdateGithubConnector(ctx context.Context, connector types.GithubConnector) (types.GithubConnector, error) {
convertedConnector, err := services.ConvertGithubConnector(connector)
if err != nil {
return nil, trace.Wrap(err)
}
updated, err := g.ClientI.UpdateGithubConnector(ctx, convertedConnector)
return updated, trace.Wrap(err)
}
// CreateGithubAuthRequest creates a new request for Github OAuth2 flow
func (a *Server) CreateGithubAuthRequest(ctx context.Context, req types.GithubAuthRequest) (*types.GithubAuthRequest, error) {
connector, err := a.getGithubConnector(ctx, req)
if err != nil {
return nil, trace.Wrap(err)
}
// requests for a web session originate from the proxy, so they are trusted
// and they're handled in such a way that minimizes misuse in the callback
// endpoint; requests for a client session (as used by tsh login) need to be
// checked, as they will point the browser away from the IdP or the web UI
// after the authentication is done
if !req.CreateWebSession {
ceremonyType := sso.CeremonyTypeLogin
if req.SSOTestFlow {
ceremonyType = sso.CeremonyTypeTest
}
if err := sso.ValidateClientRedirect(req.ClientRedirectURL, ceremonyType, connector.GetClientRedirectSettings()); err != nil {
return nil, trace.Wrap(err, InvalidClientRedirectErrorMessage)
}
}
req.StateToken, err = utils.CryptoRandomHex(defaults.TokenLenBytes)
if err != nil {
return nil, trace.Wrap(err)
}
config := newGithubOAuth2Config(connector)
req.RedirectURL = config.AuthCodeURL(req.StateToken)
a.logger.DebugContext(ctx, "Creating github auth request", "redirect_url", req.RedirectURL)
req.SetExpiry(a.GetClock().Now().UTC().Add(defaults.GithubAuthRequestTTL))
err = a.Services.CreateGithubAuthRequest(ctx, req)
if err != nil {
return nil, trace.Wrap(err)
}
return &req, nil
}
// upsertGithubConnector creates or updates a Github connector.
func (a *Server) upsertGithubConnector(ctx context.Context, connector types.GithubConnector) (types.GithubConnector, error) {
if err := checkGithubOrgSSOSupport(ctx, connector, nil, a.modules.BuildType(), a.githubOrgSSOCache, nil); err != nil {
return nil, trace.Wrap(err)
}
upserted, err := a.UpsertGithubConnector(ctx, connector)
if err != nil {
return nil, trace.Wrap(err)
}
if err := a.emitter.EmitAuditEvent(ctx, &apievents.GithubConnectorCreate{
Metadata: apievents.Metadata{
Type: events.GithubConnectorCreatedEvent,
Code: events.GithubConnectorCreatedCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
ResourceMetadata: apievents.ResourceMetadata{
Name: connector.GetName(),
},
ConnectionMetadata: authz.ConnectionMetadata(ctx),
}); err != nil {
a.logger.WarnContext(ctx, "Failed to emit GitHub connector create event", "error", err)
}
return upserted, nil
}
// createGithubConnector creates a new Github connector.
func (a *Server) createGithubConnector(ctx context.Context, connector types.GithubConnector) (types.GithubConnector, error) {
if err := checkGithubOrgSSOSupport(ctx, connector, nil, a.modules.BuildType(), a.githubOrgSSOCache, nil); err != nil {
return nil, trace.Wrap(err)
}
created, err := a.CreateGithubConnector(ctx, connector)
if err != nil {
return nil, trace.Wrap(err)
}
if err := a.emitter.EmitAuditEvent(ctx, &apievents.GithubConnectorCreate{
Metadata: apievents.Metadata{
Type: events.GithubConnectorCreatedEvent,
Code: events.GithubConnectorCreatedCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
ResourceMetadata: apievents.ResourceMetadata{
Name: connector.GetName(),
},
ConnectionMetadata: authz.ConnectionMetadata(ctx),
}); err != nil {
a.logger.WarnContext(ctx, "Failed to emit GitHub connector create event", "error", err)
}
return created, nil
}
// updateGithubConnector updates an existing Github connector.
func (a *Server) updateGithubConnector(ctx context.Context, connector types.GithubConnector) (types.GithubConnector, error) {
if err := checkGithubOrgSSOSupport(ctx, connector, nil, a.modules.BuildType(), a.githubOrgSSOCache, nil); err != nil {
return nil, trace.Wrap(err)
}
updated, err := a.UpdateGithubConnector(ctx, connector)
if err != nil {
return nil, trace.Wrap(err)
}
if err := a.emitter.EmitAuditEvent(ctx, &apievents.GithubConnectorUpdate{
Metadata: apievents.Metadata{
Type: events.GithubConnectorUpdatedEvent,
Code: events.GithubConnectorUpdatedCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
ResourceMetadata: apievents.ResourceMetadata{
Name: connector.GetName(),
},
ConnectionMetadata: authz.ConnectionMetadata(ctx),
}); err != nil {
a.logger.WarnContext(ctx, "Failed to emit GitHub connector update event", "error", err)
}
return updated, nil
}
// httpRequester allows a net/http.Client to be mocked for tests.
// TODO(capnspacehook): test without using this interface
type httpRequester interface {
Do(req *http.Request) (*http.Response, error)
}
// checkGithubOrgSSOSupport returns an error if any of the Github
// organizations specified in this connector use external SSO.
// If userTeams is not nil, only organizations that are both specified
// in conn and in userTeams will be checked. If client is nil a
// net/http.Client will be used.
func checkGithubOrgSSOSupport(ctx context.Context, conn types.GithubConnector, userTeams []GithubTeamResponse, buildType string, orgCache *utils.FnCache, client httpRequester) error {
if buildType == modules.BuildEnterprise {
return nil
}
orgs := make(map[string]struct{})
addOrg := func(org string) {
if len(userTeams) != 0 {
// Only check organizations that the user is a member of and
// that are specified in this auth connector
for _, team := range userTeams {
if org == team.Org.Login {
orgs[org] = struct{}{}
}
}
} else {
orgs[org] = struct{}{}
}
}
// Check each organization only once
// TODO: this can be removed as of Teleport 12, but we should create cluster
// alerts for anyone using the old teams_to_logins field to avoid breaking anyone
for _, mapping := range conn.GetTeamsToLogins() {
addOrg(mapping.Organization)
}
for _, mapping := range conn.GetTeamsToRoles() {
addOrg(mapping.Organization)
}
if client == nil {
var err error
client, err = defaults.HTTPClient()
if err != nil {
return trace.Wrap(err)
}
}
for org := range orgs {
usesSSO, err := utils.FnCacheGet(ctx, orgCache, org, func(ctx context.Context) (bool, error) {
return orgUsesExternalSSO(ctx, conn.GetEndpointURL(), org, client)
})
if err != nil {
return trace.Wrap(err)
}
if usesSSO {
return trace.AccessDenied(
"GitHub organization %s uses external SSO, please purchase a Teleport Enterprise license if you want to authenticate with this organization",
org,
)
}
}
return nil
}
// orgUsesExternalSSO returns true if the Github organization org
// uses external SSO.
func orgUsesExternalSSO(ctx context.Context, endpointURL, org string, client httpRequester) (bool, error) {
// A Github organization will have a "sso" page reachable if it
// supports external SSO. There doesn't seem to be any way to get this
// information from the Github REST API without being an owner of the
// Github organization, so check if this exists instead.
ssoURL, err := url.JoinPath(endpointURL, "orgs", url.PathEscape(org), "sso")
if err != nil {
return false, trace.Wrap(err)
}
const retries = 3
var resp *http.Response
for i := range retries {
var err error
var urlErr *url.Error
resp, err = makeHTTPGetReq(ctx, ssoURL, client)
// Drain and close the body regardless of outcome.
// Errors handled below.
if resp != nil {
io.Copy(io.Discard, resp.Body)
if bodyErr := resp.Body.Close(); bodyErr != nil {
logger.ErrorContext(ctx, "Error closing response body", "error", bodyErr)
}
}
// Handle makeHTTPGetReq errors.
if err == nil {
break
} else if errors.As(err, &urlErr) && urlErr.Timeout() {
if i == retries-1 {
// The connection timed out a couple of times in a row,
// stop trying and return the error.
return false, trace.ConnectionProblem(err, "Timed out trying to reach GitHub to check for organization external SSO.")
}
// Connection timed out, try to make the request again
continue
}
// Unknown error, don't try making any more requests
return false, trace.Wrap(err, "Unknown error trying to reach GitHub to check for organization external SSO")
}
// "sso" page exists, org uses external SSO
if resp.StatusCode == http.StatusOK {
return true, nil
}
// "sso" page does not exist, org does not use external SSO
return false, nil
}
func makeHTTPGetReq(ctx context.Context, url string, client httpRequester) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, defaults.HTTPRequestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, trace.Wrap(err)
}
return client.Do(req)
}
// deleteGithubConnector deletes a Github connector by name.
func (a *Server) deleteGithubConnector(ctx context.Context, connectorName string) error {
if err := a.DeleteGithubConnector(ctx, connectorName); err != nil {
return trace.Wrap(err)
}
if err := a.emitter.EmitAuditEvent(a.closeCtx, &apievents.GithubConnectorDelete{
Metadata: apievents.Metadata{
Type: events.GithubConnectorDeletedEvent,
Code: events.GithubConnectorDeletedCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
ResourceMetadata: apievents.ResourceMetadata{
Name: connectorName,
},
ConnectionMetadata: authz.ConnectionMetadata(ctx),
}); err != nil {
a.logger.WarnContext(ctx, "Failed to emit GitHub connector delete event", "error", err)
}
return nil
}
// GithubAuthRequestFromProto converts the types.GithubAuthRequest to GithubAuthRequest.
func GithubAuthRequestFromProto(req *types.GithubAuthRequest) authclient.GithubAuthRequest {
return authclient.GithubAuthRequest{
ConnectorID: req.ConnectorID,
SSHPubKey: req.SshPublicKey,
TLSPubKey: req.TlsPublicKey,
CSRFToken: req.CSRFToken,
CreateWebSession: req.CreateWebSession,
ClientRedirectURL: req.ClientRedirectURL,
}
}
type githubManager interface {
ValidateGithubAuthRedirect(ctx context.Context, diagCtx *SSODiagContext, q url.Values) (*authclient.GithubAuthResponse, error)
}
// ValidateGithubAuthCallback validates Github auth callback redirect
func (a *Server) ValidateGithubAuthCallback(ctx context.Context, q url.Values) (*authclient.GithubAuthResponse, error) {
diagCtx := NewSSODiagContext(types.KindGithub, a)
return validateGithubAuthCallbackHelper(ctx, a, diagCtx, q, a.emitter, a.logger)
}
func validateGithubAuthCallbackHelper(ctx context.Context, m githubManager, diagCtx *SSODiagContext, q url.Values, emitter apievents.Emitter, logger *slog.Logger) (*authclient.GithubAuthResponse, error) {
event := &apievents.UserLogin{
Metadata: apievents.Metadata{
Type: events.UserLoginEvent,
},
Method: events.LoginMethodGithub,
ConnectionMetadata: authz.ConnectionMetadata(ctx),
}
auth, err := m.ValidateGithubAuthRedirect(ctx, diagCtx, q)
diagCtx.Info.Error = trace.UserMessage(err)
event.AppliedLoginRules = diagCtx.Info.AppliedLoginRules
diagCtx.WriteToBackend(ctx)
claims := diagCtx.Info.GithubClaims
if claims != nil {
attributes, err := apievents.EncodeMapStrings(claims.OrganizationToTeams)
if err != nil {
event.Status.UserMessage = fmt.Sprintf("Failed to encode identity attributes: %v", err.Error())
logger.DebugContext(ctx, "Failed to encode identity attributes", "error", err)
} else {
event.IdentityAttributes = attributes
}
}
if err != nil {
event.Code = events.UserSSOLoginFailureCode
if diagCtx.Info.TestFlow {
event.Code = events.UserSSOTestFlowLoginFailureCode
}
event.Status.Success = false
event.Status.Error = trace.Unwrap(err).Error()
event.Status.UserMessage = err.Error()
if err := emitter.EmitAuditEvent(ctx, event); err != nil {
logger.WarnContext(ctx, "Failed to emit GitHub login failed event", "error", err)
}
return nil, trace.Wrap(err)
}
event.Code = events.UserSSOLoginCode
if diagCtx.Info.TestFlow {
event.Code = events.UserSSOTestFlowLoginCode
}
event.Status.Success = true
event.User = auth.Username
if err := emitter.EmitAuditEvent(ctx, event); err != nil {
logger.WarnContext(ctx, "Failed to emit GitHub login event", "error", err)
}
return auth, nil
}
func (a *Server) getGithubConnector(ctx context.Context, request types.GithubAuthRequest) (types.GithubConnector, error) {
if request.SSOTestFlow || request.AuthenticatedUser != "" {
if request.ConnectorSpec == nil {
return nil, trace.BadParameter("ConnectorSpec cannot be nil for SSOTestFlow or authenticated user flow")
}
if request.ConnectorID == "" {
return nil, trace.BadParameter("ConnectorID cannot be empty")
}
// stateless test flow
connector, err := services.NewGithubConnector(request.ConnectorID, *request.ConnectorSpec)
if err != nil {
return nil, trace.Wrap(err)
}
return connector, nil
}
// regular execution flow
connector, err := a.GetGithubConnector(ctx, request.ConnectorID, true)
if err != nil {
return nil, trace.Wrap(err)
}
connector, err = services.InitGithubConnector(connector)
if err != nil {
return nil, trace.Wrap(err)
}
return connector, nil
}
func newGithubOAuth2Config(connector types.GithubConnector) oauth2.Config {
return oauth2.Config{
ClientID: connector.GetClientID(),
ClientSecret: connector.GetClientSecret(),
RedirectURL: connector.GetRedirectURL(),
Scopes: GithubScopes,
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/%s", connector.GetEndpointURL(), GithubAuthPath),
TokenURL: fmt.Sprintf("%s/%s", connector.GetEndpointURL(), GithubTokenPath),
},
}
}
// ValidateGithubAuthRedirect validates Github auth callback redirect
func (a *Server) ValidateGithubAuthRedirect(ctx context.Context, diagCtx *SSODiagContext, q url.Values) (*authclient.GithubAuthResponse, error) {
logger := a.logger.With(teleport.ComponentKey, "github")
if errParam := q.Get("error"); errParam != "" {
// try to find request so the error gets logged against it.
state := q.Get("state")
if state != "" {
diagCtx.RequestID = state
req, err := a.Services.GetGithubAuthRequest(ctx, state)
if err == nil {
diagCtx.Info.TestFlow = req.SSOTestFlow
}
}
// optional parameter: error_description
errDesc := q.Get("error_description")
oauthErr := trace.OAuth2("invalid_request", errParam, q)
return nil, trace.WithUserMessage(oauthErr, "GitHub returned error: %v [%v]", errDesc, errParam)
}
code := q.Get("code")
if code == "" {
oauthErr := trace.OAuth2("invalid_request", "code query param must be set", q)
return nil, trace.WithUserMessage(oauthErr, "Invalid parameters received from GitHub.")
}
stateToken := q.Get("state")
if stateToken == "" {
oauthErr := trace.OAuth2("invalid_request", "missing state query param", q)
return nil, trace.WithUserMessage(oauthErr, "Invalid parameters received from GitHub.")
}
diagCtx.RequestID = stateToken
req, err := a.Services.GetGithubAuthRequest(ctx, stateToken)
if err != nil {
return nil, trace.Wrap(err, "Failed to get OIDC Auth Request.")
}
diagCtx.Info.TestFlow = req.SSOTestFlow
if req.AuthenticatedUser != "" {
return a.validateGithubAuthCallbackForAuthenticatedUser(ctx, code, req, diagCtx, logger)
}
connector, err := a.getGithubConnector(ctx, *req)
if err != nil {
return nil, trace.Wrap(err, "Failed to get GitHub connector and client.")
}
diagCtx.Info.GithubTeamsToLogins = connector.GetTeamsToLogins()
diagCtx.Info.GithubTeamsToRoles = connector.GetTeamsToRoles()
logger.DebugContext(ctx, "Connector found",
"connector", connector.GetName(),
"teams_to_logins", connector.GetTeamsToLogins(),
"roles", connector.GetTeamsToRoles(),
)
userResp, teamsResp, err := a.getGithubUserAndTeams(ctx, connector, code, diagCtx, logger)
if err != nil {
return nil, trace.Wrap(err)
}
// Github does not support OIDC so user claims have to be populated
// by making requests to Github API using the access token
claims, err := populateGithubClaims(userResp, teamsResp)
if err != nil {
return nil, trace.Wrap(err, "Failed to query GitHub API for user claims.")
}
logger.DebugContext(ctx, "Retrieved GitHub claims",
slog.Group("claims",
slog.String("user_name", claims.Username),
slog.String("user_id", claims.UserID),
slog.Any("organization_to_teams", claims.Teams),
slog.Any("roles", claims.OrganizationToTeams),
),
)
diagCtx.Info.GithubClaims = claims
// Calculate (figure out name, roles, traits, session TTL) of user and
// create the user in the backend.
params, err := a.calculateGithubUser(ctx, diagCtx, connector, claims, req)
if err != nil {
return nil, trace.Wrap(err, "Failed to calculate user attributes.")
}
diagCtx.Info.CreateUserParams = &types.CreateUserParams{
ConnectorName: params.ConnectorName,
Username: params.Username,
KubeGroups: params.KubeGroups,
KubeUsers: params.KubeUsers,
Roles: params.Roles,
Traits: params.Traits,
SessionTTL: types.Duration(params.SessionTTL),
}
user, err := a.createGithubUser(ctx, params, req.SSOTestFlow)
if err != nil {
return nil, trace.Wrap(err, "Failed to create user from provided parameters.")
}
if err := a.CallLoginHooks(ctx, user); err != nil {
return nil, trace.Wrap(err)
}
userState, err := a.GetUserOrLoginState(ctx, user.GetName())
if err != nil {
return nil, trace.Wrap(err)
}
// In test flow skip signing and creating web sessions.
if req.SSOTestFlow {
diagCtx.Info.Success = true
return &authclient.GithubAuthResponse{
Req: GithubAuthRequestFromProto(req),
Identity: userResp.makeExternalIdentity(params.ConnectorName),
Username: params.Username,
}, nil
}
// Auth was successful, return session, certificate, etc. to caller.
return a.makeGithubAuthResponse(ctx, req, userState, userResp, params.SessionTTL)
}
func (a *Server) makeGithubAuthResponse(
ctx context.Context,
req *types.GithubAuthRequest,
userState services.UserState,
githubUser *GithubUserResponse,
sessionTTL time.Duration) (*authclient.GithubAuthResponse, error) {
auth := authclient.GithubAuthResponse{
Req: GithubAuthRequestFromProto(req),
Identity: githubUser.makeExternalIdentity(req.ConnectorID),
Username: userState.GetName(),
}
// If the request is coming from a browser, create a web session.
if req.CreateWebSession {
session, err := a.CreateWebSessionFromReq(ctx, NewWebSessionRequest{
User: userState.GetName(),
Roles: userState.GetRoles(),
Traits: userState.GetTraits(),
SessionTTL: sessionTTL,
LoginTime: a.clock.Now().UTC(),
LoginIP: req.ClientLoginIP,
LoginUserAgent: req.ClientUserAgent,
AttestWebSession: true,
CreateDeviceWebToken: true,
Scope: req.Scope,
})
if err != nil {
return nil, trace.Wrap(err, "Failed to create web session.")
}
auth.Session = session
}
// If a public key was provided, sign it and return a certificate.
if len(req.SshPublicKey) != 0 || len(req.TlsPublicKey) != 0 {
sshCert, tlsCert, err := a.CreateSessionCerts(ctx, &SessionCertsRequest{
UserState: userState,
SessionTTL: sessionTTL,
SSHPubKey: req.SshPublicKey,
TLSPubKey: req.TlsPublicKey,
SSHAttestationStatement: hardwarekey.AttestationStatementFromProto(req.SshAttestationStatement),
TLSAttestationStatement: hardwarekey.AttestationStatementFromProto(req.TlsAttestationStatement),
Compatibility: req.Compatibility,
RouteToCluster: req.RouteToCluster,
KubernetesCluster: req.KubernetesCluster,
LoginIP: req.ClientLoginIP,
Scope: req.Scope,
})
if err != nil {
return nil, trace.Wrap(err, "Failed to create session certificate.")
}
clusterName, err := a.GetClusterName(ctx)
if err != nil {
return nil, trace.Wrap(err, "Failed to obtain cluster name.")
}
auth.Cert = sshCert
auth.TLSCert = tlsCert
// Return the host CA for this cluster only.
authority, err := a.GetCertAuthority(ctx, types.CertAuthID{
Type: types.HostCA,
DomainName: clusterName.GetClusterName(),
}, false)
if err != nil {
return nil, trace.Wrap(err, "Failed to obtain cluster's host CA.")
}
auth.HostSigners = append(auth.HostSigners, authority)
}
if o, err := a.ClientOptionsForLogin(userState); err == nil {
auth.ClientOptions = o
} else {
logger.WarnContext(ctx, "Failed to calculate client options for GitHub login", "username", userState.GetName(), "error", err)
}
return &auth, nil
}
func (a *Server) getGitHubAPIClient(
ctx context.Context,
connector types.GithubConnector,
code string,
diagCtx *SSODiagContext,
logger *slog.Logger,
) (*githubAPIClient, error) {
config := newGithubOAuth2Config(connector)
// exchange the authorization code received by the callback for an access token
token, err := config.Exchange(ctx, code)
if err != nil {
return nil, trace.Wrap(err, "Requesting GitHub OAuth2 token failed.")
}
scope, ok := token.Extra("scope").(string)
if !ok {
return nil, trace.BadParameter("missing or invalid scope found in GitHub OAuth2 token")
}
diagCtx.Info.GithubTokenInfo = &types.GithubTokenInfo{
TokenType: token.TokenType,
Expires: token.ExpiresIn,
Scope: scope,
}
logger.DebugContext(ctx, "Obtained OAuth2 token",
"type", token.TokenType, "expires", token.ExpiresIn, "scope", scope)
// Get the Github organizations the user is a member of so we don't
// make unnecessary API requests
apiEndpoint, err := buildAPIEndpoint(connector.GetAPIEndpointURL())
if err != nil {
return nil, trace.Wrap(err)
}
return &githubAPIClient{
token: token.AccessToken,
authServer: a,
apiEndpoint: apiEndpoint,
}, nil
}
func (a *Server) getGithubUserAndTeams(
ctx context.Context,
connector types.GithubConnector,
code string,
diagCtx *SSODiagContext,
logger *slog.Logger,
) (*GithubUserResponse, []GithubTeamResponse, error) {
if a.GithubUserAndTeamsOverride != nil {
// Allow tests to override the user and teams response instead of
// calling out to GitHub.
return a.GithubUserAndTeamsOverride()
}
ghClient, err := a.getGitHubAPIClient(ctx, connector, code, diagCtx, logger)
if err != nil {
return nil, nil, trace.Wrap(err)
}
userResp, err := ghClient.getUser()
if err != nil {
return nil, nil, trace.Wrap(err, "failed to query GitHub user info")
}
teamsResp, err := ghClient.getTeams(ctx)
if err != nil {
return nil, nil, trace.Wrap(err, "failed to query GitHub user teams")
}
logger.DebugContext(ctx, "Retrieved teams for GitHub user.", "num_teams", len(teamsResp), "github_user", userResp.Login)
// If we are running Teleport OSS, ensure that the Github organization
// the user is trying to authenticate with is not using external SSO.
// SSO is a Teleport Enterprise feature and shouldn't be allowed in OSS.
// This is checked when Github auth connectors get created or updated, but
// check again here in case the organization enabled external SSO after
// the auth connector was created.
if err := checkGithubOrgSSOSupport(ctx, connector, teamsResp, a.modules.BuildType(), a.githubOrgSSOCache, nil); err != nil {
return nil, nil, trace.Wrap(err)
}
return userResp, teamsResp, nil
}
func (a *Server) validateGithubAuthCallbackForAuthenticatedUser(
ctx context.Context,
code string,
req *types.GithubAuthRequest,
diagCtx *SSODiagContext,
logger *slog.Logger,
) (*authclient.GithubAuthResponse, error) {
connector, err := a.getGithubConnector(ctx, *req)
if err != nil {
return nil, trace.Wrap(err, "Failed to get GitHub connector and client.")
}
ghClient, err := a.getGitHubAPIClient(ctx, connector, code, diagCtx, logger)
if err != nil {
return nil, trace.Wrap(err)
}
githubUser, err := ghClient.getUser()
if err != nil {
return nil, trace.Wrap(err)
}
// Attach the new (but secondary) identity.
teleportUser, err := a.GetUser(ctx, req.AuthenticatedUser, false)
if err != nil {
return nil, trace.Wrap(err)
}
teleportUser.SetGithubIdentities([]types.ExternalIdentity{
githubUser.makeExternalIdentity(req.ConnectorID),
})
// Instead of updating the user, refresh the user login state.
userState, err := a.ulsGenerator.Refresh(ctx, teleportUser, a.UserLoginStates)
if err != nil {
return nil, trace.Wrap(err)
}
return a.makeGithubAuthResponse(ctx, req, userState, githubUser, req.CertTTL)
}
// buildAPIEndpoint takes a URL of a GitHub API endpoint and returns only
// the joined host and path.
func buildAPIEndpoint(apiEndpointURLStr string) (string, error) {
apiEndpointURL, err := url.Parse(apiEndpointURLStr)
if err != nil {
return "", trace.Wrap(err)
}
apiEndpoint, err := url.JoinPath(apiEndpointURL.Host, apiEndpointURL.Path)
if err != nil {
return "", trace.Wrap(err)
}
return apiEndpoint, nil
}
// CreateUserParams is a set of parameters used to create a user for an
// external identity provider.
type CreateUserParams struct {
// ConnectorName is the name of the connector for the identity provider.
ConnectorName string
// Username is the Teleport user name .
Username string
// UserID is the unique ID of the GitHub user.
UserID string
// KubeGroups is the list of Kubernetes groups this user belongs to.
KubeGroups []string
// KubeUsers is the list of Kubernetes users this user belongs to.
KubeUsers []string
// Roles is the list of Roles this user is assigned to.
Roles []string
// Traits is the list of Traits for this user.
Traits map[string][]string
// SessionTTL is how long this session will last.
SessionTTL time.Duration
}
func (a *Server) calculateGithubUser(ctx context.Context, diagCtx *SSODiagContext, connector types.GithubConnector, claims *types.GithubClaims, request *types.GithubAuthRequest) (*CreateUserParams, error) {
p := CreateUserParams{
ConnectorName: connector.GetName(),
Username: claims.Username,
UserID: claims.UserID,
}
// Calculate logins, kubegroups, roles, and traits.
p.Roles, p.KubeGroups, p.KubeUsers = connector.MapClaims(*claims)
if len(p.Roles) == 0 {
return nil, trace.Wrap(ErrGithubNoTeams)
}
p.Traits = map[string][]string{
constants.TraitLogins: {p.Username},
constants.TraitKubeGroups: p.KubeGroups,
constants.TraitKubeUsers: p.KubeUsers,
teleport.TraitTeams: claims.Teams,
}
evaluationInput := &loginrule.EvaluationInput{
Traits: p.Traits,
}
evaluationOutput, err := a.GetLoginRuleEvaluator().Evaluate(ctx, evaluationInput)
if err != nil {
return nil, trace.Wrap(err)
}
p.Traits = evaluationOutput.Traits
diagCtx.Info.AppliedLoginRules = evaluationOutput.AppliedRules
// Kube groups and users are ultimately only set in the traits, not any
// other property of the User. In case the login rules changed the relevant
// traits values, reset the value on the user params for accurate
// diagnostics.
p.KubeGroups = p.Traits[constants.TraitKubeGroups]
p.KubeUsers = p.Traits[constants.TraitKubeUsers]
// Pick smaller for role: session TTL from role or requested TTL.
roles, err := services.FetchRolesWithContext(p.Roles, a, services.RoleTemplateContext{
Username: p.Username,
Traits: p.Traits,
})
if err != nil {
return nil, trace.Wrap(err)
}
roleTTL := roles.AdjustSessionTTL(apidefaults.MaxCertDuration)
p.SessionTTL = utils.MinTTL(roleTTL, request.CertTTL)
return &p, nil
}
func (a *Server) createGithubUser(ctx context.Context, p *CreateUserParams, dryRun bool) (types.User, error) {
a.logger.DebugContext(ctx, "Generating dynamic GitHub identity",
"connector_name", p.ConnectorName,
"user_name", p.Username,
"role", p.Roles,
"dry_run", dryRun,
)
expires := a.GetClock().Now().UTC().Add(p.SessionTTL)
user := &types.UserV2{
Kind: types.KindUser,
Version: types.V2,
Metadata: types.Metadata{
Name: p.Username,
Namespace: apidefaults.Namespace,
Expires: &expires,
},
Spec: types.UserSpecV2{
Roles: p.Roles,
Traits: p.Traits,
GithubIdentities: []types.ExternalIdentity{{
ConnectorID: p.ConnectorName,
Username: p.Username,
UserID: p.UserID,
}},
CreatedBy: types.CreatedBy{
User: types.UserRef{Name: teleport.UserSystem},
Time: a.GetClock().Now().UTC(),
Connector: &types.ConnectorRef{
Type: constants.Github,
ID: p.ConnectorName,
Identity: p.Username,
},
},
},
}
if dryRun {
return user, nil
}
existingUser, err := a.Services.GetUser(ctx, p.Username, false)
if err != nil && !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
}
if existingUser != nil {
ref := user.GetCreatedBy().Connector
if !ref.IsSameProvider(existingUser.GetCreatedBy().Connector) {
return nil, trace.AlreadyExists("local user %q already exists and is not a GitHub user",
existingUser.GetName())
}
user.SetRevision(existingUser.GetRevision())
if _, err := a.UpdateUser(ctx, user); err != nil {
return nil, trace.Wrap(err)
}
} else {
if _, err := a.CreateUser(ctx, user); err != nil {
return nil, trace.Wrap(err)
}
}
return user, nil
}
// populateGithubClaims builds a GithubClaims using queried
// user, organization and teams information.
func populateGithubClaims(user *GithubUserResponse, teams []GithubTeamResponse) (*types.GithubClaims, error) {
orgToTeams := make(map[string][]string)
teamList := make([]string, 0, len(teams))
for _, team := range teams {
orgToTeams[team.Org.Login] = append(
orgToTeams[team.Org.Login], team.Slug)
teamList = append(teamList, team.Name)
}
if len(orgToTeams) == 0 {
return nil, trace.AccessDenied(
"list of user teams is empty, did you grant access?")
}
return &types.GithubClaims{
Username: user.Login,
OrganizationToTeams: orgToTeams,
Teams: teamList,
UserID: user.getIDStr(),
}, nil
}
// githubAPIClient is a tiny wrapper around some of Github APIs
type githubAPIClient struct {
// token is the access token retrieved during OAuth2 flow
token string
// authServer points to the Auth Server.
authServer *Server
// apiEndpoint is the API endpoint of the Github instance
// to connect to.
apiEndpoint string
}
// GithubUserResponse represents response from "user" API call
type GithubUserResponse struct {
// Login is the username
Login string `json:"login"`
// ID is the user ID
ID int64 `json:"id"`
}
func (r GithubUserResponse) getIDStr() string {
return fmt.Sprintf("%v", r.ID)
}
func (r GithubUserResponse) makeExternalIdentity(connectorID string) types.ExternalIdentity {
return types.ExternalIdentity{
ConnectorID: connectorID,
Username: r.Login,
UserID: r.getIDStr(),
}
}
// getEmails retrieves a list of emails for authenticated user
func (c *githubAPIClient) getUser() (*GithubUserResponse, error) {
// Ignore pagination links, we should never get more than a single user here.
bytes, _, err := c.get("user")
if err != nil {
return nil, trace.Wrap(err)
}
var user GithubUserResponse
err = json.Unmarshal(bytes, &user)
if err != nil {
return nil, trace.Wrap(err)
}
return &user, nil
}
// GithubTeamResponse represents a single team entry in the "teams" API response
type GithubTeamResponse struct {
// Name is the team name
Name string `json:"name"`
// Slug is the team ID
Slug string `json:"slug"`
// Org describes the organization this team is a part of
Org GithubOrgResponse `json:"organization"`
}
// GithubOrgResponse represents a Github organization
type GithubOrgResponse struct {
// Login is the organization ID
Login string `json:"login"`
}
// getTeams retrieves a list of teams authenticated user belongs to.
func (c *githubAPIClient) getTeams(ctx context.Context) ([]GithubTeamResponse, error) {
var result []GithubTeamResponse
bytes, nextPage, err := c.get("user/teams")
if err != nil {
return nil, trace.Wrap(err)
}
// Extract the first page of results and append them to the full result set.
var teams []GithubTeamResponse
err = json.Unmarshal(bytes, &teams)
if err != nil {
return nil, trace.Wrap(err)
}
result = append(result, teams...)
// If the response returned a next page link, continue following the next
// page links until all teams have been retrieved.
var count int
for nextPage != "" {
// To prevent this from looping forever, don't fetch more than a set number
// of pages, print an error when it does happen, and return the results up
// to that point.
if count > MaxPages {
const warningMessage = "Truncating list of teams used to populate claims: " +
"hit maximum number pages that can be fetched from GitHub."
// Print warning to Teleport logs as well as the Audit Log.
c.authServer.logger.WarnContext(ctx, warningMessage)
if err := c.authServer.emitter.EmitAuditEvent(c.authServer.closeCtx, &apievents.UserLogin{
Metadata: apievents.Metadata{
Type: events.UserLoginEvent,
Code: events.UserSSOLoginFailureCode,
},
Method: events.LoginMethodGithub,
Status: apievents.Status{
Success: false,
Error: warningMessage,
},
ConnectionMetadata: authz.ConnectionMetadata(ctx),
}); err != nil {
c.authServer.logger.WarnContext(ctx, "Failed to emit GitHub login failure event", "error", err)
}
return result, nil
}
u, err := url.Parse(nextPage)
if err != nil {
return nil, trace.Wrap(err)
}
bytes, nextPage, err = c.get(u.RequestURI())
if err != nil {
return nil, trace.Wrap(err)
}
err = json.Unmarshal(bytes, &teams)
if err != nil {
return nil, trace.Wrap(err)
}
// Append this page of teams to full result set.
result = append(result, teams...)
count = count + 1
}
return result, nil
}
// get makes a GET request to the provided URL using the client's token for auth
func (c *githubAPIClient) get(page string) ([]byte, string, error) {
request, err := http.NewRequest("GET", formatGithubURL(c.apiEndpoint, page), nil)
if err != nil {
return nil, "", trace.Wrap(err)
}
request.Header.Set("Authorization", fmt.Sprintf("token %v", c.token))
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, "", trace.Wrap(err)
}
defer response.Body.Close()
bytes, err := utils.ReadAtMost(response.Body, teleport.MaxHTTPResponseSize)
if err != nil {
return nil, "", trace.Wrap(err)
}
if response.StatusCode != http.StatusOK {
return nil, "", trace.AccessDenied("bad response: %v %v",
response.StatusCode, string(bytes))
}
// Parse web links header to extract any pagination links. This is used to
// return the next link which can be used in a loop to pull back all data.
wls := utils.ParseWebLinks(response)
return bytes, wls.NextPage, nil
}
// formatGithubURL is a helper for formatting github api request URLs.
func formatGithubURL(host string, path string) string {
return fmt.Sprintf("https://%s/%s", host, strings.TrimPrefix(path, "/"))
}
const (
// GithubAuthPath is the GitHub authorization endpoint
GithubAuthPath = "login/oauth/authorize"
// GithubTokenPath is the GitHub token exchange endpoint
GithubTokenPath = "login/oauth/access_token"
// MaxPages is the maximum number of pagination links that will be followed.
MaxPages = 99
)
// GithubScopes is a list of scopes requested during OAuth2 flow
var GithubScopes = []string{
// read:org grants read-only access to user's team memberships
"read:org",
}