connector: add github connector
Add interface for oauth2 connectors and a github implementation.
This commit is contained in:
parent
17cac69e80
commit
0d0790e05c
4 changed files with 389 additions and 0 deletions
145
connector/connector_github.go
Normal file
145
connector/connector_github.go
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
chttp "github.com/coreos/go-oidc/http"
|
||||||
|
"github.com/coreos/go-oidc/oauth2"
|
||||||
|
"github.com/coreos/go-oidc/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
GitHubConnectorType = "github"
|
||||||
|
githubAuthURL = "https://github.com/login/oauth/authorize"
|
||||||
|
githubTokenURL = "https://github.com/login/oauth/access_token"
|
||||||
|
githubAPIUserURL = "https://api.github.com/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterConnectorConfigType(GitHubConnectorType, func() ConnectorConfig { return &GitHubConnectorConfig{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitHubConnectorConfig struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ClientID string `json:"clientID"`
|
||||||
|
ClientSecret string `json:"clientSecret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *GitHubConnectorConfig) ConnectorID() string {
|
||||||
|
return cfg.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *GitHubConnectorConfig) ConnectorType() string {
|
||||||
|
return GitHubConnectorType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *GitHubConnectorConfig) Connector(ns url.URL, lf oidc.LoginFunc, tpls *template.Template) (Connector, error) {
|
||||||
|
ns.Path = path.Join(ns.Path, httpPathCallback)
|
||||||
|
oauth2Conn, err := newGitHubConnector(cfg.ClientID, cfg.ClientSecret, ns.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &OAuth2Connector{
|
||||||
|
id: cfg.ID,
|
||||||
|
loginFunc: lf,
|
||||||
|
cbURL: ns,
|
||||||
|
conn: oauth2Conn,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubOAuth2Connector struct {
|
||||||
|
clientID string
|
||||||
|
clientSecret string
|
||||||
|
client *oauth2.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGitHubConnector(clientID, clientSecret, cbURL string) (oauth2Connector, error) {
|
||||||
|
config := oauth2.Config{
|
||||||
|
Credentials: oauth2.ClientCredentials{ID: clientID, Secret: clientSecret},
|
||||||
|
AuthURL: githubAuthURL,
|
||||||
|
TokenURL: githubTokenURL,
|
||||||
|
Scope: []string{"user:email"},
|
||||||
|
AuthMethod: oauth2.AuthMethodClientSecretPost,
|
||||||
|
RedirectURL: cbURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := oauth2.NewClient(http.DefaultClient, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &githubOAuth2Connector{
|
||||||
|
clientID: clientID,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
client: cli,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// standard error form returned by github
|
||||||
|
type githubError struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err githubError) Error() string {
|
||||||
|
return fmt.Sprintf("github: %s", err.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *githubOAuth2Connector) Client() *oauth2.Client {
|
||||||
|
return c.client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *githubOAuth2Connector) Identity(cli chttp.Client) (oidc.Identity, error) {
|
||||||
|
req, err := http.NewRequest("GET", githubAPIUserURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return oidc.Identity{}, err
|
||||||
|
}
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return oidc.Identity{}, fmt.Errorf("get: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode >= 400 && resp.StatusCode < 600:
|
||||||
|
// attempt to decode error from github
|
||||||
|
var authErr githubError
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&authErr); err != nil {
|
||||||
|
return oidc.Identity{}, oauth2.NewError(oauth2.ErrorAccessDenied)
|
||||||
|
}
|
||||||
|
return oidc.Identity{}, authErr
|
||||||
|
case resp.StatusCode == http.StatusOK:
|
||||||
|
default:
|
||||||
|
return oidc.Identity{}, fmt.Errorf("unexpected status from providor %s", resp.Status)
|
||||||
|
}
|
||||||
|
var user struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||||
|
return oidc.Identity{}, fmt.Errorf("getting user info: %v", err)
|
||||||
|
}
|
||||||
|
name := user.Name
|
||||||
|
if name == "" {
|
||||||
|
name = user.Login
|
||||||
|
}
|
||||||
|
return oidc.Identity{
|
||||||
|
ID: strconv.FormatInt(user.ID, 10),
|
||||||
|
Name: name,
|
||||||
|
Email: user.Email,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *githubOAuth2Connector) Healthy() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *githubOAuth2Connector) TrustedEmailProvider() bool {
|
||||||
|
return false
|
||||||
|
}
|
41
connector/connector_github_test.go
Normal file
41
connector/connector_github_test.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
githubExampleUser = `{"login":"octocat","id":1,"name": "monalisa octocat","email": "octocat@github.com"}`
|
||||||
|
githubExampleError = `{"message":"Bad credentials","documentation_url":"https://developer.github.com/v3"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGitHubIdentity(t *testing.T) {
|
||||||
|
tests := []oauth2IdentityTest{
|
||||||
|
{
|
||||||
|
urlResps: map[string]response{
|
||||||
|
githubAPIUserURL: {http.StatusOK, githubExampleUser},
|
||||||
|
},
|
||||||
|
want: oidc.Identity{
|
||||||
|
Name: "monalisa octocat",
|
||||||
|
ID: "1",
|
||||||
|
Email: "octocat@github.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlResps: map[string]response{
|
||||||
|
githubAPIUserURL: {http.StatusUnauthorized, githubExampleError},
|
||||||
|
},
|
||||||
|
wantErr: githubError{
|
||||||
|
Message: "Bad credentials",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conn, err := newGitHubConnector("fakeclientid", "fakeclientsecret", "http://examle.com/auth/github/callback")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runOAuth2IdentityTests(t, conn, tests)
|
||||||
|
}
|
140
connector/connector_oauth2.go
Normal file
140
connector/connector_oauth2.go
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/coreos/dex/pkg/log"
|
||||||
|
chttp "github.com/coreos/go-oidc/http"
|
||||||
|
"github.com/coreos/go-oidc/oauth2"
|
||||||
|
"github.com/coreos/go-oidc/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type oauth2Connector interface {
|
||||||
|
Client() *oauth2.Client
|
||||||
|
|
||||||
|
// Identity uses a HTTP client authenticated as the end user to construct
|
||||||
|
// an OIDC identity for that user.
|
||||||
|
Identity(cli chttp.Client) (oidc.Identity, error)
|
||||||
|
|
||||||
|
// Healthy it should attempt to determine if the connector's credientials
|
||||||
|
// are valid.
|
||||||
|
Healthy() error
|
||||||
|
|
||||||
|
TrustedEmailProvider() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuth2Connector struct {
|
||||||
|
id string
|
||||||
|
loginFunc oidc.LoginFunc
|
||||||
|
cbURL url.URL
|
||||||
|
conn oauth2Connector
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OAuth2Connector) ID() string {
|
||||||
|
return c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OAuth2Connector) Healthy() error {
|
||||||
|
return c.conn.Healthy()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OAuth2Connector) Sync() chan struct{} {
|
||||||
|
stop := make(chan struct{}, 1)
|
||||||
|
return stop
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OAuth2Connector) TrustedEmailProvider() bool {
|
||||||
|
return c.conn.TrustedEmailProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OAuth2Connector) LoginURL(sessionKey, prompt string) (string, error) {
|
||||||
|
return c.conn.Client().AuthCodeURL(sessionKey, oauth2.GrantTypeAuthCode, prompt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OAuth2Connector) Register(mux *http.ServeMux, errorURL url.URL) {
|
||||||
|
mux.Handle(c.cbURL.Path, c.handleCallbackFunc(c.loginFunc, errorURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OAuth2Connector) handleCallbackFunc(lf oidc.LoginFunc, errorURL url.URL) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
e := q.Get("error")
|
||||||
|
if e != "" {
|
||||||
|
redirectError(w, errorURL, q)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := q.Get("code")
|
||||||
|
if code == "" {
|
||||||
|
q.Set("error", oauth2.ErrorInvalidRequest)
|
||||||
|
q.Set("error_description", "code query param must be set")
|
||||||
|
redirectError(w, errorURL, q)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionKey := q.Get("state")
|
||||||
|
|
||||||
|
token, err := c.conn.Client().RequestToken(oauth2.GrantTypeAuthCode, code)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to verify auth code with issuer: %v", err)
|
||||||
|
q.Set("error", oauth2.ErrorUnsupportedResponseType)
|
||||||
|
q.Set("error_description", "unable to verify auth code with issuer")
|
||||||
|
redirectError(w, errorURL, q)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ident, err := c.conn.Identity(newAuthenticatedClient(token, http.DefaultClient))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to retrieve identity: %v", err)
|
||||||
|
q.Set("error", oauth2.ErrorUnsupportedResponseType)
|
||||||
|
q.Set("error_description", "unable to retrieve identity from issuer")
|
||||||
|
redirectError(w, errorURL, q)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURL, err := lf(ident, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to log in %#v: %v", ident, err)
|
||||||
|
q.Set("error", oauth2.ErrorAccessDenied)
|
||||||
|
q.Set("error_description", "login failed")
|
||||||
|
redirectError(w, errorURL, q)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Location", redirectURL)
|
||||||
|
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authedClient authenticates all requests as the end user.
|
||||||
|
type authedClient struct {
|
||||||
|
token oauth2.TokenResponse
|
||||||
|
cli chttp.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthenticatedClient(token oauth2.TokenResponse, cli chttp.Client) chttp.Client {
|
||||||
|
return &authedClient{token, cli}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *authedClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("Authorization", tokenType(c.token)+" "+c.token.AccessToken)
|
||||||
|
return c.cli.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the canonical name of the token type if non-empty, else "Bearer".
|
||||||
|
// Take from golang.org/x/oauth2
|
||||||
|
func tokenType(token oauth2.TokenResponse) string {
|
||||||
|
if strings.EqualFold(token.TokenType, "bearer") {
|
||||||
|
return "Bearer"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(token.TokenType, "mac") {
|
||||||
|
return "MAC"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(token.TokenType, "basic") {
|
||||||
|
return "Basic"
|
||||||
|
}
|
||||||
|
if token.TokenType != "" {
|
||||||
|
return token.TokenType
|
||||||
|
}
|
||||||
|
return "Bearer"
|
||||||
|
}
|
63
connector/connector_oauth2_test.go
Normal file
63
connector/connector_oauth2_test.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/oidc"
|
||||||
|
"github.com/kylelemons/godebug/pretty"
|
||||||
|
)
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauth2IdentityTest struct {
|
||||||
|
urlResps map[string]response
|
||||||
|
want oidc.Identity
|
||||||
|
wantErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeClient func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
// implement github.com/coreos/go-oidc/oauth2.Client
|
||||||
|
func (f fakeClient) Do(r *http.Request) (*http.Response, error) {
|
||||||
|
return f(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOAuth2IdentityTests(t *testing.T, conn oauth2Connector, tests []oauth2IdentityTest) {
|
||||||
|
for i, tt := range tests {
|
||||||
|
f := func(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, ok := tt.urlResps[req.URL.String()]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected request URL: %s", req.URL.String())
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: resp.statusCode,
|
||||||
|
Body: ioutil.NopCloser(strings.NewReader(resp.body)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
got, err := conn.Identity(fakeClient(f))
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("case %d: failed to get identity=%v", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if diff := pretty.Compare(tt.want, got); diff != "" {
|
||||||
|
t.Errorf("case %d: Compare(want, got) = %v", i, diff)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("case %d: want error=%v, got=<nil>", i, tt.wantErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if diff := pretty.Compare(tt.wantErr, err); diff != "" {
|
||||||
|
t.Errorf("case %d: Compare(wantErr, gotErr) = %v", i, diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in a new issue