// Package github provides authentication strategies using GitHub.
package github

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"errors"
	"fmt"
	"io/ioutil"
	"net"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"time"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/github"

	"github.com/coreos/dex/connector"
	"github.com/sirupsen/logrus"
)

const (
	apiURL = "https://api.github.com"
	// GitHub requires this scope to access '/user' and '/user/emails' API endpoints.
	scopeEmail = "user:email"
	// GitHub requires this scope to access '/user/teams' and '/orgs' API endpoints
	// which are used when a client includes the 'groups' scope.
	scopeOrgs = "read:org"
)

// Pagination URL patterns
// https://developer.github.com/v3/#pagination
var reNext = regexp.MustCompile("<([^>]+)>; rel=\"next\"")
var reLast = regexp.MustCompile("<([^>]+)>; rel=\"last\"")

// Config holds configuration options for github logins.
type Config struct {
	ClientID     string `json:"clientID"`
	ClientSecret string `json:"clientSecret"`
	RedirectURI  string `json:"redirectURI"`
	Org          string `json:"org"`
	Orgs         []Org  `json:"orgs"`
	HostName     string `json:"hostName"`
	RootCA       string `json:"rootCA"`
}

// Org holds org-team filters, in which teams are optional.
type Org struct {

	// Organization name in github (not slug, full name). Only users in this github
	// organization can authenticate.
	Name string `json:"name"`

	// Names of teams in a github organization. A user will be able to
	// authenticate if they are members of at least one of these teams. Users
	// in the organization can authenticate if this field is omitted from the
	// config file.
	Teams []string `json:"teams,omitempty"`
}

// Open returns a strategy for logging in through GitHub.
func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {

	if c.Org != "" {
		// Return error if both 'org' and 'orgs' fields are used.
		if len(c.Orgs) > 0 {
			return nil, errors.New("github: cannot use both 'org' and 'orgs' fields simultaneously")
		}
		logger.Warnln("github: legacy field 'org' being used. Switch to the newer 'orgs' field structure")
	}

	g := githubConnector{
		redirectURI:  c.RedirectURI,
		org:          c.Org,
		orgs:         c.Orgs,
		clientID:     c.ClientID,
		clientSecret: c.ClientSecret,
		apiURL:       apiURL,
		logger:       logger,
	}

	if c.HostName != "" {
		// ensure this is a hostname and not a URL or path.
		if strings.Contains(c.HostName, "/") {
			return nil, errors.New("invalid hostname: hostname cannot contain `/`")
		}

		g.hostName = c.HostName
		g.apiURL = "https://" + c.HostName + "/api/v3"
	}

	if c.RootCA != "" {
		if c.HostName == "" {
			return nil, errors.New("invalid connector config: Host name field required for a root certificate file")
		}
		g.rootCA = c.RootCA

		var err error
		if g.httpClient, err = newHTTPClient(g.rootCA); err != nil {
			return nil, fmt.Errorf("failed to create HTTP client: %v", err)
		}

	}

	return &g, nil
}

type connectorData struct {
	// GitHub's OAuth2 tokens never expire. We don't need a refresh token.
	AccessToken string `json:"accessToken"`
}

var (
	_ connector.CallbackConnector = (*githubConnector)(nil)
	_ connector.RefreshConnector  = (*githubConnector)(nil)
)

type githubConnector struct {
	redirectURI  string
	org          string
	orgs         []Org
	clientID     string
	clientSecret string
	logger       logrus.FieldLogger
	// apiURL defaults to "https://api.github.com"
	apiURL string
	// hostName of the GitHub enterprise account.
	hostName string
	// Used to support untrusted/self-signed CA certs.
	rootCA string
	// HTTP Client that trusts the custom delcared rootCA cert.
	httpClient *http.Client
}

// groupsRequired returns whether dex requires GitHub's 'read:org' scope. Dex
// needs 'read:org' if 'orgs' or 'org' fields are populated in a config file.
// Clients can require 'groups' scope without setting 'orgs'/'org'.
func (c *githubConnector) groupsRequired(groupScope bool) bool {
	return len(c.orgs) > 0 || c.org != "" || groupScope
}

func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
	// 'read:org' scope is required by the GitHub API, and thus for dex to ensure
	// a user is a member of orgs and teams provided in configs.
	githubScopes := []string{scopeEmail}
	if c.groupsRequired(scopes.Groups) {
		githubScopes = append(githubScopes, scopeOrgs)
	}

	endpoint := github.Endpoint
	// case when it is a GitHub Enterprise account.
	if c.hostName != "" {
		endpoint = oauth2.Endpoint{
			AuthURL:  "https://" + c.hostName + "/login/oauth/authorize",
			TokenURL: "https://" + c.hostName + "/login/oauth/access_token",
		}
	}

	return &oauth2.Config{
		ClientID:     c.clientID,
		ClientSecret: c.clientSecret,
		Endpoint:     endpoint,
		Scopes:       githubScopes,
	}
}

func (c *githubConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
	if c.redirectURI != callbackURL {
		return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
	}

	return c.oauth2Config(scopes).AuthCodeURL(state), nil
}

type oauth2Error struct {
	error            string
	errorDescription string
}

func (e *oauth2Error) Error() string {
	if e.errorDescription == "" {
		return e.error
	}
	return e.error + ": " + e.errorDescription
}

// newHTTPClient returns a new HTTP client that trusts the custom delcared rootCA cert.
func newHTTPClient(rootCA string) (*http.Client, error) {
	tlsConfig := tls.Config{RootCAs: x509.NewCertPool()}
	rootCABytes, err := ioutil.ReadFile(rootCA)
	if err != nil {
		return nil, fmt.Errorf("failed to read root-ca: %v", err)
	}
	if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
		return nil, fmt.Errorf("no certs found in root CA file %q", rootCA)
	}

	return &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tlsConfig,
			Proxy:           http.ProxyFromEnvironment,
			DialContext: (&net.Dialer{
				Timeout:   30 * time.Second,
				KeepAlive: 30 * time.Second,
				DualStack: true,
			}).DialContext,
			MaxIdleConns:          100,
			IdleConnTimeout:       90 * time.Second,
			TLSHandshakeTimeout:   10 * time.Second,
			ExpectContinueTimeout: 1 * time.Second,
		},
	}, nil
}

func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
	q := r.URL.Query()
	if errType := q.Get("error"); errType != "" {
		return identity, &oauth2Error{errType, q.Get("error_description")}
	}

	oauth2Config := c.oauth2Config(s)

	ctx := r.Context()
	// GitHub Enterprise account
	if c.httpClient != nil {
		ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
	}

	token, err := oauth2Config.Exchange(ctx, q.Get("code"))
	if err != nil {
		return identity, fmt.Errorf("github: failed to get token: %v", err)
	}

	client := oauth2Config.Client(ctx, token)

	user, err := c.user(ctx, client)
	if err != nil {
		return identity, fmt.Errorf("github: get user: %v", err)
	}

	username := user.Name
	if username == "" {
		username = user.Login
	}
	identity = connector.Identity{
		UserID:        strconv.Itoa(user.ID),
		Username:      username,
		Email:         user.Email,
		EmailVerified: true,
	}

	// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
	if c.groupsRequired(s.Groups) {
		groups, err := c.getGroups(ctx, client, s.Groups, user.Login)
		if err != nil {
			return identity, err
		}
		identity.Groups = groups
	}

	if s.OfflineAccess {
		data := connectorData{AccessToken: token.AccessToken}
		connData, err := json.Marshal(data)
		if err != nil {
			return identity, fmt.Errorf("marshal connector data: %v", err)
		}
		identity.ConnectorData = connData
	}

	return identity, nil
}

func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
	if len(identity.ConnectorData) == 0 {
		return identity, errors.New("no upstream access token found")
	}

	var data connectorData
	if err := json.Unmarshal(identity.ConnectorData, &data); err != nil {
		return identity, fmt.Errorf("github: unmarshal access token: %v", err)
	}

	client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
	user, err := c.user(ctx, client)
	if err != nil {
		return identity, fmt.Errorf("github: get user: %v", err)
	}

	username := user.Name
	if username == "" {
		username = user.Login
	}
	identity.Username = username
	identity.Email = user.Email

	// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
	if c.groupsRequired(s.Groups) {
		groups, err := c.getGroups(ctx, client, s.Groups, user.Login)
		if err != nil {
			return identity, err
		}
		identity.Groups = groups
	}

	return identity, nil
}

// getGroups retrieves GitHub orgs and teams a user is in, if any.
func (c *githubConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
	if len(c.orgs) > 0 {
		return c.groupsForOrgs(ctx, client, userLogin)
	} else if c.org != "" {
		return c.teamsForOrg(ctx, client, c.org)
	}
	return nil, nil
}

// groupsForOrgs enforces org and team constraints on user authorization
// Cases in which user is authorized:
// 	N orgs, no teams: user is member of at least 1 org
// 	N orgs, M teams per org: user is member of any team from at least 1 org
// 	N-1 orgs, M teams per org, 1 org with no teams: user is member of any team
// from at least 1 org, or member of org with no teams
func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client, userName string) (groups []string, err error) {
	var inOrgNoTeams bool
	for _, org := range c.orgs {
		inOrg, err := c.userInOrg(ctx, client, userName, org.Name)
		if err != nil {
			return groups, err
		}
		if !inOrg {
			continue
		}

		teams, err := c.teamsForOrg(ctx, client, org.Name)
		if err != nil {
			return groups, err
		}
		// User is in at least one org. User is authorized if no teams are specified
		// in config; include all teams in claim. Otherwise filter out teams not in
		// 'teams' list in config.
		if len(org.Teams) == 0 {
			inOrgNoTeams = true
		} else if teams = filterTeams(teams, org.Teams); len(teams) == 0 {
			c.logger.Infof("github: user %q in org %q but no teams", userName, org.Name)
		}

		// Orgs might have the same team names. We append orgPrefix to team name,
		// i.e. "org:team", to make team names unique across orgs.
		orgPrefix := org.Name + ":"
		for _, teamName := range teams {
			groups = append(groups, orgPrefix+teamName)
		}
	}
	if inOrgNoTeams || len(groups) > 0 {
		return
	}
	return groups, fmt.Errorf("github: user %q not in required orgs or teams", userName)
}

// Filter the users' team memberships by 'teams' from config.
func filterTeams(userTeams, configTeams []string) (teams []string) {
	teamFilter := make(map[string]struct{})
	for _, team := range configTeams {
		if _, ok := teamFilter[team]; !ok {
			teamFilter[team] = struct{}{}
		}
	}
	for _, team := range userTeams {
		if _, ok := teamFilter[team]; ok {
			teams = append(teams, team)
		}
	}
	return
}

// get creates a "GET `apiURL`" request with context, sends the request using
// the client, and decodes the resulting response body into v. A pagination URL
// is returned if one exists. Any errors encountered when building requests,
// sending requests, and reading and decoding response data are returned.
func get(ctx context.Context, client *http.Client, apiURL string, v interface{}) (string, error) {
	req, err := http.NewRequest("GET", apiURL, nil)
	if err != nil {
		return "", fmt.Errorf("github: new req: %v", err)
	}
	req = req.WithContext(ctx)
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("github: get URL %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			return "", fmt.Errorf("github: read body: %v", err)
		}
		return "", fmt.Errorf("%s: %s", resp.Status, body)
	}

	if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
		return "", fmt.Errorf("failed to decode response: %v", err)
	}

	return getPagination(apiURL, resp), nil
}

// getPagination checks the "Link" header field for "next" or "last" pagination
// URLs, and returns true only if a "next" URL is found. The next pages' URL is
// returned if a "next" URL is found. apiURL is returned if apiURL equals the
// "last" URL or no "next" or "last" URL are found.
//
// https://developer.github.com/v3/#pagination
func getPagination(apiURL string, resp *http.Response) string {
	if resp == nil {
		return ""
	}

	links := resp.Header.Get("Link")
	if len(reLast.FindStringSubmatch(links)) > 1 {
		lastPageURL := reLast.FindStringSubmatch(links)[1]
		if apiURL == lastPageURL {
			return ""
		}
	} else {
		return ""
	}

	if len(reNext.FindStringSubmatch(links)) > 1 {
		return reNext.FindStringSubmatch(links)[1]
	}

	return ""
}

// user holds GitHub user information (relevant to dex) as defined by
// https://developer.github.com/v3/users/#response-with-public-profile-information
type user struct {
	Name  string `json:"name"`
	Login string `json:"login"`
	ID    int    `json:"id"`
	Email string `json:"email"`
}

// user queries the GitHub API for profile information using the provided client.
//
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (c *githubConnector) user(ctx context.Context, client *http.Client) (user, error) {
	// https://developer.github.com/v3/users/#get-the-authenticated-user
	var u user
	if _, err := get(ctx, client, c.apiURL+"/user", &u); err != nil {
		return u, err
	}

	// Only pulic user emails are returned by 'GET /user'. u.Email will be empty
	// if a users' email is private. We must retrieve private emails explicitly.
	if u.Email == "" {
		var err error
		if u.Email, err = c.userEmail(ctx, client); err != nil {
			return u, err
		}
	}
	return u, nil
}

// userEmail holds GitHub user email information as defined by
// https://developer.github.com/v3/users/emails/#response
type userEmail struct {
	Email      string `json:"email"`
	Verified   bool   `json:"verified"`
	Primary    bool   `json:"primary"`
	Visibility string `json:"visibility"`
}

// userEmail queries the GitHub API for a users' email information using the
// provided client. Only returns the users' verified, primary email (private or
// public).
//
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (string, error) {
	apiURL := c.apiURL + "/user/emails"
	for {
		// https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user
		var (
			emails []userEmail
			err    error
		)
		if apiURL, err = get(ctx, client, apiURL, &emails); err != nil {
			return "", err
		}

		for _, email := range emails {
			/*
				if GitHub Enterprise, set email.Verified to true
				This change being made because GitHub Enterprise does not
				support email verification. CircleCI indicated that GitHub
				advised them not to check for verified emails
				(https://circleci.com/enterprise/changelog/#1-47-1).
				In addition, GitHub Enterprise support replied to a support
				ticket with "There is no way to verify an email address in 
				GitHub Enterprise."			
			*/
			if c.hostName != "" {
				email.Verified = true
			}
			
			if email.Verified && email.Primary {
				return email.Email, nil
			}
		}

		if apiURL == "" {
			break
		}
	}

	return "", errors.New("github: user has no verified, primary email")
}

// userInOrg queries the GitHub API for a users' org membership.
//
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, userName, orgName string) (bool, error) {
	// requester == user, so GET-ing this endpoint should return 404/302 if user
	// is not a member
	//
	// https://developer.github.com/v3/orgs/members/#check-membership
	apiURL := fmt.Sprintf("%s/orgs/%s/members/%s", c.apiURL, orgName, userName)

	req, err := http.NewRequest("GET", apiURL, nil)

	if err != nil {
		return false, fmt.Errorf("github: new req: %v", err)
	}
	req = req.WithContext(ctx)
	resp, err := client.Do(req)
	if err != nil {
		return false, fmt.Errorf("github: get teams: %v", err)
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusNoContent:
	case http.StatusFound, http.StatusNotFound:
		c.logger.Infof("github: user %q not in org %q or application not authorized to read org data", userName, orgName)
	default:
		err = fmt.Errorf("github: unexpected return status: %q", resp.Status)
	}

	// 204 if user is a member
	return resp.StatusCode == http.StatusNoContent, err
}

// teams holds GitHub a users' team information as defined by
// https://developer.github.com/v3/orgs/teams/#response-12
type team struct {
	Name string `json:"name"`
	Org  struct {
		Login string `json:"login"`
	} `json:"organization"`
}

// teamsForOrg queries the GitHub API for team membership within a specific organization.
//
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (c *githubConnector) teamsForOrg(ctx context.Context, client *http.Client, orgName string) ([]string, error) {
	apiURL, groups := c.apiURL+"/user/teams", []string{}
	for {
		// https://developer.github.com/v3/orgs/teams/#list-user-teams
		var (
			teams []team
			err   error
		)
		if apiURL, err = get(ctx, client, apiURL, &teams); err != nil {
			return nil, fmt.Errorf("github: get teams: %v", err)
		}

		for _, team := range teams {
			if team.Org.Login == orgName {
				groups = append(groups, team.Name)
			}
		}

		if apiURL == "" {
			break
		}
	}

	return groups, nil
}
