package auth
import (
"context"
"encoding/json"
"errors"
"net/url"
"slices"
"strings"
"github.com/gin-gonic/gin"
"github.com/sigbit/mcp-auth-proxy/v2/pkg/utils"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)
type githubProvider struct {
endpoint string
oauth2 oauth2.Config
allowedUsers []string
allowedOrgs []string
}
func NewGithubProvider(oauthEndpoint, apiURL, clientID, clientSecret, externalURL string, allowedUsers []string, allowedOrgs []string) (Provider, error) {
r, err := url.JoinPath(externalURL, GitHubCallbackEndpoint)
if err != nil {
return nil, err
}
scopes := []string{}
if len(allowedOrgs) > 0 {
scopes = append(scopes, "read:org")
}
if apiURL == "" {
apiURL = "https://api.github.com"
}
oauth2Endpoint := github.Endpoint
if oauthEndpoint != "" {
authURL, err := url.JoinPath(oauthEndpoint, "/login/oauth/authorize")
if err != nil {
return nil, err
}
tokenURL, err := url.JoinPath(oauthEndpoint, "/login/oauth/access_token")
if err != nil {
return nil, err
}
deviceAuthURL, err := url.JoinPath(oauthEndpoint, "/login/device/code")
if err != nil {
return nil, err
}
oauth2Endpoint = oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
DeviceAuthURL: deviceAuthURL,
}
}
return &githubProvider{
endpoint: apiURL,
oauth2: oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: r,
Scopes: scopes,
Endpoint: oauth2Endpoint,
},
allowedUsers: allowedUsers,
allowedOrgs: allowedOrgs,
}, nil
}
func (p *githubProvider) Name() string {
return "GitHub"
}
func (p *githubProvider) Type() string {
return "github"
}
func (p *githubProvider) RedirectURL() string {
return GitHubCallbackEndpoint
}
func (p *githubProvider) AuthURL() string {
return GitHubAuthEndpoint
}
func (p *githubProvider) AuthCodeURL(state string) (string, error) {
authURL := p.oauth2.AuthCodeURL(state)
return authURL, nil
}
func (p *githubProvider) Exchange(c *gin.Context, state string) (*oauth2.Token, error) {
if c.Query("state") != state {
return nil, errors.New("invalid OAuth state")
}
code := c.Query("code")
token, err := p.oauth2.Exchange(c, code)
if err != nil {
return nil, err
}
return token, nil
}
func (p *githubProvider) Authorization(ctx context.Context, token *oauth2.Token) (bool, string, map[string]any, error) {
client := p.oauth2.Client(ctx, token)
resp1, err := client.Get(utils.Must(url.JoinPath(p.endpoint, "/user")))
if err != nil {
return false, "", nil, err
}
if resp1.StatusCode < 200 || resp1.StatusCode >= 300 {
return false, "", nil, errors.New("failed to get user info from GitHub API: " + resp1.Status)
}
defer resp1.Body.Close()
var userInfoMap map[string]any
if err := json.NewDecoder(resp1.Body).Decode(&userInfoMap); err != nil {
return false, "", nil, err
}
login, _ := userInfoMap["login"].(string)
if len(p.allowedUsers) == 0 && len(p.allowedOrgs) == 0 {
return true, login, userInfoMap, nil
}
if slices.Contains(p.allowedUsers, login) {
return true, login, userInfoMap, nil
}
allowedOrgTeams := []string{}
allowedOrgs := []string{}
for _, allowedOrg := range p.allowedOrgs {
if strings.Contains(allowedOrg, ":") {
allowedOrgTeams = append(allowedOrgTeams, allowedOrg)
} else {
allowedOrgs = append(allowedOrgs, allowedOrg)
}
}
if len(allowedOrgs) > 0 {
resp2, err := client.Get(utils.Must(url.JoinPath(p.endpoint, "/user/orgs")))
if err != nil {
return false, "", nil, err
}
if resp2.StatusCode < 200 || resp2.StatusCode >= 300 {
return false, "", nil, errors.New("failed to get user info from GitHub API: " + resp2.Status)
}
defer resp2.Body.Close()
var orgInfo []struct {
Login string `json:"login"`
}
if err := json.NewDecoder(resp2.Body).Decode(&orgInfo); err != nil {
return false, "", nil, err
}
for _, o := range orgInfo {
if slices.Contains(allowedOrgs, o.Login) {
return true, login, userInfoMap, nil
}
}
}
if len(allowedOrgTeams) > 0 {
resp3, err := client.Get(utils.Must(url.JoinPath(p.endpoint, "/user/teams")))
if err != nil {
return false, "", nil, err
}
if resp3.StatusCode < 200 || resp3.StatusCode >= 300 {
return false, "", nil, errors.New("failed to get user info from GitHub API: " + resp3.Status)
}
defer resp3.Body.Close()
var teamInfo []struct {
Organization struct {
Login string `json:"login"`
} `json:"organization"`
Slug string `json:"slug"`
}
if err := json.NewDecoder(resp3.Body).Decode(&teamInfo); err != nil {
return false, "", nil, err
}
for _, team := range teamInfo {
if slices.Contains(allowedOrgTeams, team.Organization.Login+":"+team.Slug) {
return true, login, userInfoMap, nil
}
}
}
return false, login, userInfoMap, nil
}