Making Rancher 2 and GitLab OAuth Authentication work together

We are big fans of GitLab for the DevOps Pipeline, and of Rancher 2 for Kubernetes Orchestration. However, there is no Single-Signon between GitLab and Rancher. It is requested some times, but I totally understand that the Rancher folks need to prioritize which services they support.

So, after figuring out that the GitHub oAuth API is supported by Rancher, I checked the source code and found that actually not many APIs of GitHub are needed.

Thus, I built a small proxy in Golang which implements the relevant parts of the GitHub API, and sends the requests to GitLab. And because GitLab's and GitHub's API are quite similar and both implement oAuth, it all fits into a single file and is stateless :)

You'll find the full source code, including the needed adjustments to the GitLab config below.

Happy k8s-ing, rancher'ing and gitlab-ing,
and a Happy and Healthy New Year 2021,
Sebastian
PS: Let me know what you think about this on Twitter :)

#!/bin/bash set -ex go build -o rancher_gitlab_proxy main.go GOOS=linux go build -o rancher_gitlab_proxy_linux main.go
# in GitLab config - gitlab.rb nginx['custom_gitlab_server_config'] = " # CONNECTION TO rancher-gitlab-proxy BEGIN location /login/oauth/authorize { proxy_pass http://127.0.0.1:8888; } location /login/oauth/access_token { proxy_pass http://127.0.0.1:8888; } location /api/v3/user { proxy_pass http://127.0.0.1:8888; } location /api/v3/teams/ { proxy_pass http://127.0.0.1:8888; } location /api/v3/search/users { proxy_pass http://127.0.0.1:8888; } # CONNECTION TO rancher-gitlab-proxy END "
module sandstorm.de/rancher-gitlab-proxy go 1.15 require ( github.com/julienschmidt/httprouter v1.3.0 github.com/xanzy/go-gitlab v0.40.2 )
package main import ( "encoding/json" "fmt" "github.com/julienschmidt/httprouter" "github.com/xanzy/go-gitlab" "net/http" "strconv" "strings" ) // !!! ADJUST const gitlab_url = "https://gitlab.com" const rancher_url = "https://rancher-url.de" // !!! ADJUST ///////////////// MAIN func main() { router := httprouter.New() router.GET("/login/oauth/authorize", oauthAuthorize) router.POST("/login/oauth/access_token", oauthAccessToken) router.GET("/api/v3/user", apiV3User) router.GET("/api/v3/user/:id", apiV3UserId) router.GET("/api/v3/teams/:id", apiV3TeamsId) router.GET("/api/v3/search/users", apiV3SearchUsers) fmt.Println("Listening to 127.0.0.1:8888") if err := http.ListenAndServe("127.0.0.1:8888", router); err != nil { panic(err) } } ///////////////// API func oauthAuthorize(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { v := req.URL.Query() v.Add("response_type", "code") v.Add("scope", "read_api") target := gitlab_url + "/oauth/authorize?" + v.Encode() http.Redirect(w, req, target, http.StatusTemporaryRedirect) } func oauthAccessToken(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { v := req.URL.Query() v.Add("grant_type", "authorization_code") v.Add("redirect_uri", rancher_url + "/verify-auth") target := gitlab_url + "/oauth/token?" + v.Encode() http.Redirect(w, req, target, http.StatusTemporaryRedirect) } func apiV3User(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { gitlabClient := createGitlabClient(req) gitlabUser, _, err := gitlabClient.Users.CurrentUser() if err != nil { panic(err) } githubAccount := convertGitlabUserToAccount(gitlabUser) jsonStr, _ := json.Marshal(githubAccount) w.Write(jsonStr) } func apiV3UserId(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { // Workaround to deal with routing library if ps.ByName("id") == "orgs" { apiV3UserOrgs(w, req, ps) return } if ps.ByName("id") == "teams" { apiV3UserTeams(w, req, ps) return } gitlabClient := createGitlabClient(req) id, _ := strconv.Atoi(ps.ByName("id")) // user gitlabUser, _, err := gitlabClient.Users.GetUser(id) if err != nil { panic(err) } githubAccount := convertGitlabUserToAccount(gitlabUser) jsonStr, _ := json.Marshal(githubAccount) w.Write(jsonStr) } func apiV3UserOrgs(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { result := make([]string, 0) jsonStr, _ := json.Marshal(result) w.Write(jsonStr) } func apiV3UserTeams(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { gitlabClient := createGitlabClient(req) allAvailable := false result := make([]Team, 0, 0) listGroupsOptions := &gitlab.ListGroupsOptions{ ListOptions: gitlab.ListOptions{ }, // here, we only want to search for groups WHICH WE ARE MEMBER OF!!! AllAvailable: &allAvailable, } for { gitlabGroups, resp, err := gitlabClient.Groups.ListGroups(listGroupsOptions) if err != nil { panic(err) } for _, gitlabGroup := range gitlabGroups { team := convertGitlabGroupToTeam(gitlabGroup) result = append(result, *team) } // Exit the loop when we've seen all pages. if resp.CurrentPage >= resp.TotalPages { break } // Update the page number to get the next page. listGroupsOptions.ListOptions.Page = resp.NextPage } jsonStr, _ := json.Marshal(result) w.Write(jsonStr) } func apiV3TeamsId(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { gitlabClient := createGitlabClient(req) id, _ := strconv.Atoi(ps.ByName("id")) gitlabGroup, _, err := gitlabClient.Groups.GetGroup(id) if err != nil { panic(err) } team := convertGitlabGroupToTeam(gitlabGroup) jsonStr, _ := json.Marshal(team) w.Write(jsonStr) } type searchResult struct { Items []*Account `json:"items"` } func apiV3SearchUsers(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { query := req.URL.Query().Get("q") gitlabClient := createGitlabClient(req) searchResult := &searchResult{ Items: make([]*Account, 0), } shouldSearchUsers := true shouldSearchOrgs := true if strings.Contains(query, "type:org") { shouldSearchUsers = false shouldSearchOrgs = true query = strings.ReplaceAll(query, "type:org", "") } if shouldSearchOrgs { allAvailable := true gitlabGroups, _, err := gitlabClient.Groups.ListGroups(&gitlab.ListGroupsOptions{ Search: &query, // we want to find ALL groups (which are not fully private) AllAvailable: &allAvailable, }) if err != nil { panic(err) } for _, gitlabGroup := range gitlabGroups { githubOrg := convertGitlabGroupToAccount(gitlabGroup) searchResult.Items = append(searchResult.Items, githubOrg) } } if shouldSearchUsers { gitlabUsers, _, err := gitlabClient.Users.ListUsers(&gitlab.ListUsersOptions{ Search: &query, }) if err != nil { panic(err) } for _, gitlabUser := range gitlabUsers { githubAccount := convertGitlabUserToAccount(gitlabUser) searchResult.Items = append(searchResult.Items, githubAccount) } } jsonStr, _ := json.Marshal(searchResult) w.Write(jsonStr) } ///////////////// HELPERS // https://docs.github.com/en/free-pro-team@latest/rest/reference/users#get-the-authenticated-user // copied from https://github.com/rancher/rancher/blob/2506427ba7bd31edf12f7110b7fdb8b2defe8eb3/pkg/auth/providers/github/github_account.go#L12 type Account struct { ID int `json:"id,omitempty"` Login string `json:"login,omitempty"` Name string `json:"name,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` HTMLURL string `json:"html_url,omitempty"` // "Type" must be "user", "team", oder "org" Type string `json:"type,omitempty"` } //Team defines properties a team on github has type Team struct { ID int `json:"id,omitempty"` Organization map[string]interface{} `json:"organization,omitempty"` Name string `json:"name,omitempty"` Slug string `json:"slug,omitempty"` } func createGitlabClient(req *http.Request) *gitlab.Client { authorizationHeader := req.Header.Get("Authorization") t := strings.Split(authorizationHeader, " ") token := t[1] gitlabClient, err := gitlab.NewOAuthClient(token, gitlab.WithBaseURL(gitlab_url + "/api/v4")) if err != nil { panic(err) } return gitlabClient } func convertGitlabUserToAccount(gitlabUser *gitlab.User) *Account { return &Account{ ID: gitlabUser.ID, Login: gitlabUser.Username, Name: gitlabUser.Name, AvatarURL: gitlabUser.AvatarURL, HTMLURL: "", Type: "user", } } func convertGitlabGroupToAccount(gitlabGroup *gitlab.Group) *Account { return &Account{ ID: gitlabGroup.ID, Login: gitlabGroup.Path, Name: gitlabGroup.Name, AvatarURL: gitlabGroup.AvatarURL, HTMLURL: "", Type: "team", } } func convertGitlabGroupToTeam(gitlabGroup *gitlab.Group) *Team { org := make(map[string]interface{}) org["login"] = gitlabGroup.Path org["avatar_url"] = gitlabGroup.AvatarURL return &Team{ ID: gitlabGroup.ID, Organization: org, Name: gitlabGroup.Name, Slug: gitlabGroup.Path, } }
[Unit] Description=rancher-gitlab-proxy Wants=network-online.target After=network-online.target [Service] Type=simple User=gitlab-www Group=gitlab-www ExecStart=/usr/local/bin/rancher_gitlab_proxy SyslogIdentifier=rancher-gitlab-proxy Restart=always [Install] WantedBy=multi-user.target