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