diff --git a/pkg/auth/oauth/external/handler.go b/pkg/auth/oauth/external/handler.go index 3e8f47981d03..4622a3756710 100644 --- a/pkg/auth/oauth/external/handler.go +++ b/pkg/auth/oauth/external/handler.go @@ -1,6 +1,7 @@ package external import ( + "encoding/base64" "errors" "fmt" "net/http" @@ -147,11 +148,11 @@ func (defaultState) Generate(w http.ResponseWriter, req *http.Request) (string, "csrf": {"..."}, // TODO: get csrf "then": {req.URL.String()}, } - return state.Encode(), nil + return encodeState(state) } func (defaultState) Check(state string, w http.ResponseWriter, req *http.Request) (bool, error) { - values, err := url.ParseQuery(state) + values, err := decodeState(state) if err != nil { return false, err } @@ -169,7 +170,7 @@ func (defaultState) Check(state string, w http.ResponseWriter, req *http.Request } func (defaultState) AuthenticationSucceeded(user user.Info, state string, w http.ResponseWriter, req *http.Request) (bool, error) { - values, err := url.ParseQuery(state) + values, err := decodeState(state) if err != nil { return false, err } @@ -182,3 +183,15 @@ func (defaultState) AuthenticationSucceeded(user user.Info, state string, w http http.Redirect(w, req, then, http.StatusFound) return true, nil } + +func encodeState(values url.Values) (string, error) { + return base64.URLEncoding.EncodeToString([]byte(values.Encode())), nil +} + +func decodeState(state string) (url.Values, error) { + decodedState, err := base64.URLEncoding.DecodeString(state) + if err != nil { + return nil, err + } + return url.ParseQuery(string(decodedState)) +} diff --git a/pkg/auth/oauth/external/keycloak/keycloak.go b/pkg/auth/oauth/external/keycloak/keycloak.go new file mode 100644 index 000000000000..b088d757ccf6 --- /dev/null +++ b/pkg/auth/oauth/external/keycloak/keycloak.go @@ -0,0 +1,147 @@ +package keycloak + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/url" + "strings" + + "github.com/RangelReale/osincli" + "github.com/golang/glog" + + authapi "github.com/openshift/origin/pkg/auth/api" + "github.com/openshift/origin/pkg/auth/oauth/external" +) + +type provider struct { + Realm string `json:"realm"` + RealmPublicKey string `json:"realm-public-key"` + ClientID string `json:"resource"` + Credentials struct { + Secret string `json:"secret"` + } `json:"credentials"` + AuthServerURL string `json:"auth-server-url"` +} + +type keycloakUser struct { + id string + username string + email string + firstName string + lastName string + realmRoles string +} + +func NewProviderFromFile(keycloakClientConfigFile string) (external.Provider, error) { + keycloakClientConfigBytes, err := ioutil.ReadFile(keycloakClientConfigFile) + if err != nil { + glog.Errorf("Error loading Keycloak config: %s", err) + return nil, err + } + return NewProviderFromBytes(keycloakClientConfigBytes) +} + +func NewProviderFromBytes(keycloakClientConfigBytes []byte) (external.Provider, error) { + p := provider{} + err := json.Unmarshal(keycloakClientConfigBytes, &p) + if err != nil { + glog.Errorf("Error parsing Keycloak config: %s", err) + return nil, err + } + return p, nil +} + +// NewConfig implements external/interfaces/Provider.NewConfig +func (p provider) NewConfig() (*osincli.ClientConfig, error) { + realmURL, err := url.Parse(p.AuthServerURL) + if err != nil { + return nil, err + } + realmURL.Path += "/realms/" + p.Realm + "/" + loginPath := url.URL{ + Path: "tokens/login", + } + tokenPath := url.URL{ + Path: "tokens/access/codes", + } + config := &osincli.ClientConfig{ + ClientId: p.ClientID, + ClientSecret: p.Credentials.Secret, + ErrorsInStatusCode: true, + SendClientSecretInParams: true, + AuthorizeUrl: realmURL.ResolveReference(&loginPath).String(), + TokenUrl: realmURL.ResolveReference(&tokenPath).String(), + } + return config, nil +} + +// AddCustomParameters implements external/interfaces/Provider.AddCustomParameters +func (p provider) AddCustomParameters(req *osincli.AuthorizeRequest) { +} + +// GetUserIdentity implements external/interfaces/Provider.GetUserIdentity +func (p provider) GetUserIdentity(data *osincli.AccessData) (authapi.UserIdentityInfo, bool, error) { + idToken, ok := data.ResponseData["id_token"].(string) + if !ok { + return nil, false, fmt.Errorf("No id_token returned in %v", data.ResponseData) + } + + userdata, err := decodeJWT(idToken) + glog.V(4).Infof("userdata=%v", userdata) + if err != nil { + return nil, false, err + } + + username, _ := userdata["preferred_username"].(string) + if username == "" { + return nil, false, errors.New("Could not retrieve Keycloak username") + } + + identity := &authapi.DefaultUserIdentityInfo{ + UserName: username, + Extra: map[string]string{ + "name": userdata["name"].(string), + "email": userdata["email"].(string), + }, + } + glog.V(4).Infof("identity=%v", identity) + + return identity, true, nil +} + +// Decode JWT +// http://openid.net/specs/draft-jones-json-web-token-07.html +func decodeJWT(jwt string) (map[string]interface{}, error) { + jwtParts := strings.Split(jwt, ".") + if len(jwtParts) != 3 { + return nil, fmt.Errorf("Invalid JSON Web Token: expected 3 parts, got %d", len(jwtParts)) + } + + encodedPayload := jwtParts[1] + glog.V(4).Infof("got encodedPayload") + + // Re-pad, if needed + if l := len(encodedPayload) % 4; l != 0 { + padding := strings.Repeat("=", 4-l) + encodedPayload += padding + glog.V(4).Infof("added padding: %s\n", padding) + } + + decodedPayload, err := base64.StdEncoding.DecodeString(encodedPayload) + if err != nil { + return nil, fmt.Errorf("Error decoding payload: %v\n", err) + } + glog.V(4).Infof("got decodedPayload") + + var data map[string]interface{} + err = json.Unmarshal([]byte(decodedPayload), &data) + if err != nil { + return nil, fmt.Errorf("Error parsing token: %v\n", err) + } + glog.V(4).Infof("got id_token data") + + return data, nil +} diff --git a/pkg/auth/oauth/external/keycloak/keycloak_test.go b/pkg/auth/oauth/external/keycloak/keycloak_test.go new file mode 100644 index 000000000000..bc7230aa60aa --- /dev/null +++ b/pkg/auth/oauth/external/keycloak/keycloak_test.go @@ -0,0 +1,12 @@ +package keycloak + +import ( + "testing" + + "github.com/openshift/origin/pkg/auth/oauth/external" +) + +func TestKeycloak(t *testing.T) { + provider, _ := NewProviderFromBytes([]byte("")) + _ = external.Provider(provider) +} diff --git a/pkg/cmd/server/origin/auth.go b/pkg/cmd/server/origin/auth.go index b2faac8ecf6b..2d88de4eaf39 100644 --- a/pkg/cmd/server/origin/auth.go +++ b/pkg/cmd/server/origin/auth.go @@ -34,6 +34,7 @@ import ( "github.com/openshift/origin/pkg/auth/oauth/external" "github.com/openshift/origin/pkg/auth/oauth/external/github" "github.com/openshift/origin/pkg/auth/oauth/external/google" + "github.com/openshift/origin/pkg/auth/oauth/external/keycloak" "github.com/openshift/origin/pkg/auth/oauth/handlers" "github.com/openshift/origin/pkg/auth/oauth/registry" authnregistry "github.com/openshift/origin/pkg/auth/oauth/registry" @@ -108,6 +109,8 @@ const ( AuthHandlerGithub AuthHandlerType = "github" // AuthHandlerGoogle redirects unauthenticated requests to Google to request an OAuth token. AuthHandlerGoogle AuthHandlerType = "google" + // AuthHandlerKeycloak redirects unauthenticated requests to Keycloak to request an OAuth token. + AuthHandlerKeycloak AuthHandlerType = "keycloak" // AuthHandlerDeny treats unauthenticated requests as failures AuthHandlerDeny AuthHandlerType = "deny" ) @@ -376,7 +379,7 @@ func (c *AuthConfig) getAuthenticationHandler(mux cmdutil.Mux, errorHandler hand var authHandler handlers.AuthenticationHandler authHandlerType := c.AuthHandler switch authHandlerType { - case AuthHandlerGithub, AuthHandlerGoogle: + case AuthHandlerGithub, AuthHandlerGoogle, AuthHandlerKeycloak: callbackPath := path.Join(OpenShiftOAuthCallbackPrefix, string(authHandlerType)) userRegistry := useretcd.New(c.EtcdHelper, user.NewDefaultUserInitStrategy()) identityMapper := identitymapper.NewAlwaysCreateUserIdentityToUserMapper(string(authHandlerType) /*for now*/, userRegistry) @@ -386,6 +389,12 @@ func (c *AuthConfig) getAuthenticationHandler(mux cmdutil.Mux, errorHandler hand oauthProvider = google.NewProvider(c.GoogleClientID, c.GoogleClientSecret) } else if authHandlerType == AuthHandlerGithub { oauthProvider = github.NewProvider(c.GithubClientID, c.GithubClientSecret) + } else if authHandlerType == AuthHandlerKeycloak { + var err error + oauthProvider, err = keycloak.NewProviderFromFile(c.KeycloakClientConfigFile) + if err != nil { + return nil, fmt.Errorf("unexpected error creating KeyCloak OAuth provider: %v", err) + } } state := external.DefaultState() @@ -468,7 +477,7 @@ func (c *AuthConfig) getAuthenticationSuccessHandler() handlers.AuthenticationSu } switch c.AuthHandler { - case AuthHandlerGithub, AuthHandlerGoogle: + case AuthHandlerGithub, AuthHandlerGoogle, AuthHandlerKeycloak: successHandlers = append(successHandlers, external.DefaultState().(handlers.AuthenticationSuccessHandler)) case AuthHandlerLogin: successHandlers = append(successHandlers, redirectSuccessHandler{}) diff --git a/pkg/cmd/server/origin/auth_config.go b/pkg/cmd/server/origin/auth_config.go index 935a8679b141..fad631d1c60b 100644 --- a/pkg/cmd/server/origin/auth_config.go +++ b/pkg/cmd/server/origin/auth_config.go @@ -79,6 +79,10 @@ type AuthConfig struct { GithubClientID string // GithubClientID is the client_secret of a client registered with the GitHub OAuth provider. GithubClientSecret string + + // KeycloakClientConfigFile is the json file used to configure the Keycloak OAuth provider. + // This file can be generated using the contents from the "Installation" tab for the OAuth client in the Keycloak admin console. + KeycloakClientConfigFile string } func BuildAuthConfig(options configapi.MasterConfig) (*AuthConfig, error) { @@ -137,6 +141,8 @@ func BuildAuthConfig(options configapi.MasterConfig) (*AuthConfig, error) { // GitHub config GithubClientID: cmdutil.Env("OPENSHIFT_OAUTH_GITHUB_CLIENT_ID", ""), GithubClientSecret: cmdutil.Env("OPENSHIFT_OAUTH_GITHUB_CLIENT_SECRET", ""), + // Keycloak config + KeycloakClientConfigFile: cmdutil.Env("OPENSHIFT_OAUTH_KEYCLOAK_CLIENT_CONFIG_FILE", ""), } return ret, nil