From fd5e508f1cb98d38ce1d6667ff6fe3b6b96a9d81 Mon Sep 17 00:00:00 2001 From: Eric Chiang Date: Mon, 8 Aug 2016 11:45:17 -0700 Subject: [PATCH] *: implement the OpenID Connect connector --- cmd/poke/config.go | 20 +++--- connector/oidc/oidc.go | 131 ++++++++++++++++++++++++++++++++++++++++ example/config-dev.yaml | 8 +++ 3 files changed, 150 insertions(+), 9 deletions(-) diff --git a/cmd/poke/config.go b/cmd/poke/config.go index fc06b208..89d1ae21 100644 --- a/cmd/poke/config.go +++ b/cmd/poke/config.go @@ -7,6 +7,7 @@ import ( "github.com/coreos/poke/connector/github" "github.com/coreos/poke/connector/ldap" "github.com/coreos/poke/connector/mock" + "github.com/coreos/poke/connector/oidc" "github.com/coreos/poke/storage" "github.com/coreos/poke/storage/kubernetes" "github.com/coreos/poke/storage/memory" @@ -100,33 +101,34 @@ func (c *Connector) UnmarshalYAML(unmarshal func(interface{}) error) error { c.Name = connectorMetadata.Name c.ID = connectorMetadata.ID + var err error switch c.Type { case "mock": var config struct { Config mock.Config `yaml:"config"` } - if err := unmarshal(&config); err != nil { - return err - } + err = unmarshal(&config) c.Config = &config.Config case "ldap": var config struct { Config ldap.Config `yaml:"config"` } - if err := unmarshal(&config); err != nil { - return err - } + err = unmarshal(&config) c.Config = &config.Config case "github": var config struct { Config github.Config `yaml:"config"` } - if err := unmarshal(&config); err != nil { - return err + err = unmarshal(&config) + c.Config = &config.Config + case "oidc": + var config struct { + Config oidc.Config `yaml:"config"` } + err = unmarshal(&config) c.Config = &config.Config default: return fmt.Errorf("unknown connector type %q", c.Type) } - return nil + return err } diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index 41bef5eb..17aef1e2 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -1,2 +1,133 @@ // Package oidc implements logging in through OpenID Connect providers. package oidc + +import ( + "errors" + "fmt" + "net/http" + "os" + + "github.com/ericchiang/oidc" + "golang.org/x/net/context" + "golang.org/x/oauth2" + + "github.com/coreos/poke/connector" +) + +// Config holds configuration options for OpenID Connect logins. +type Config struct { + Issuer string `yaml:"issuer"` + ClientID string `yaml:"clientID"` + ClientSecret string `yaml:"clientSecret"` + RedirectURI string `yaml:"redirectURI"` + + Scopes []string `yaml:"scopes"` // defaults to "profile" and "email" +} + +// Open returns a connector which can be used to login users through an upstream +// OpenID Connect provider. +func (c *Config) Open() (conn connector.Connector, err error) { + ctx, cancel := context.WithCancel(context.Background()) + + provider, err := oidc.NewProvider(ctx, c.Issuer) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to get provider: %v", err) + } + + scopes := []string{oidc.ScopeOpenID} + if len(c.Scopes) > 0 { + scopes = append(scopes, c.Scopes...) + } else { + scopes = append(scopes, "profile", "email") + } + + clientID := os.ExpandEnv(c.ClientID) + return &oidcConnector{ + redirectURI: c.RedirectURI, + oauth2Config: &oauth2.Config{ + ClientID: clientID, + ClientSecret: os.ExpandEnv(c.ClientSecret), + Endpoint: provider.Endpoint(), + Scopes: scopes, + RedirectURL: c.RedirectURI, + }, + verifier: provider.NewVerifier(ctx, + oidc.VerifyExpiry(), + oidc.VerifyAudience(clientID), + ), + }, nil +} + +var ( + _ connector.CallbackConnector = (*oidcConnector)(nil) +) + +type oidcConnector struct { + redirectURI string + oauth2Config *oauth2.Config + verifier *oidc.IDTokenVerifier + ctx context.Context + cancel context.CancelFunc +} + +func (c *oidcConnector) Close() error { + c.cancel() + return nil +} + +func (c *oidcConnector) LoginURL(callbackURL, state string) (string, error) { + if c.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL did not match the URL in the config") + } + return c.oauth2Config.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 +} + +func (c *oidcConnector) HandleCallback(r *http.Request) (identity connector.Identity, state string, err error) { + q := r.URL.Query() + if errType := q.Get("error"); errType != "" { + return identity, "", &oauth2Error{errType, q.Get("error_description")} + } + token, err := c.oauth2Config.Exchange(c.ctx, q.Get("code")) + if err != nil { + return identity, "", fmt.Errorf("oidc: failed to get token: %v", err) + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return identity, "", errors.New("oidc: no id_token in token response") + } + idToken, err := c.verifier.Verify(rawIDToken) + if err != nil { + return identity, "", fmt.Errorf("oidc: failed to verify ID Token: %v", err) + } + + var claims struct { + Username string `json:"name"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + } + if err := idToken.Claims(&claims); err != nil { + return identity, "", fmt.Errorf("oidc: failed to decode claims: %v", err) + } + + identity = connector.Identity{ + UserID: idToken.Subject, + Username: claims.Username, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + } + return identity, q.Get("state"), nil +} diff --git a/example/config-dev.yaml b/example/config-dev.yaml index d3d01afb..65267d31 100644 --- a/example/config-dev.yaml +++ b/example/config-dev.yaml @@ -18,6 +18,14 @@ connectors: clientSecret: "$GITHUB_CLIENT_SECRET" redirectURI: http://127.0.0.1:5556/callback/github org: kubernetes +- type: oidc + id: google + name: Google Account + config: + issuer: https://accounts.google.com + clientID: "$GOOGLE_OAUTH2_CLIENT_ID" + clientSecret: "$GOOGLE_OAUTH2_CLIENT_SECRET" + redirectURI: http://127.0.0.1:5556/callback/google staticClients: - id: example-app