diff --git a/cmd/dex-worker/main.go b/cmd/dex-worker/main.go index ae960ed3..e24491c6 100644 --- a/cmd/dex-worker/main.go +++ b/cmd/dex-worker/main.go @@ -45,6 +45,7 @@ func main() { emailConfig := fs.String("email-cfg", "./static/fixtures/emailer.json", "configures emailer.") enableRegistration := fs.Bool("enable-registration", false, "Allows users to self-register") + enableClientRegistration := fs.Bool("enable-client-registration", false, "Allow dynamic registration of clients") noDB := fs.Bool("no-db", false, "manage entities in-process w/o any encryption, used only for single-node testing") @@ -117,14 +118,15 @@ func main() { } scfg := server.ServerConfig{ - IssuerURL: *issuer, - TemplateDir: *templates, - EmailTemplateDirs: emailTemplateDirs, - EmailFromAddress: *emailFrom, - EmailerConfigFile: *emailConfig, - IssuerName: *issuerName, - IssuerLogoURL: *issuerLogoURL, - EnableRegistration: *enableRegistration, + IssuerURL: *issuer, + TemplateDir: *templates, + EmailTemplateDirs: emailTemplateDirs, + EmailFromAddress: *emailFrom, + EmailerConfigFile: *emailConfig, + IssuerName: *issuerName, + IssuerLogoURL: *issuerLogoURL, + EnableRegistration: *enableRegistration, + EnableClientRegistration: *enableClientRegistration, } if *noDB { diff --git a/server/client_registration.go b/server/client_registration.go new file mode 100644 index 00000000..f53cc90d --- /dev/null +++ b/server/client_registration.go @@ -0,0 +1,57 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/coreos/dex/pkg/log" + "github.com/coreos/go-oidc/oauth2" + "github.com/coreos/go-oidc/oidc" +) + +const ( + invalidRedirectURI = "invalid_redirect_uri" + invalidClientMetadata = "invalid_client_metadata" +) + +func (s *Server) handleClientRegistration(w http.ResponseWriter, r *http.Request) { + resp, err := s.handleClientRegistrationRequest(r) + if err != nil { + code := http.StatusBadRequest + if err.Type == oauth2.ErrorServerError { + code = http.StatusInternalServerError + } + writeResponseWithBody(w, code, err) + } else { + writeResponseWithBody(w, http.StatusCreated, resp) + } +} + +func (s *Server) handleClientRegistrationRequest(r *http.Request) (*oidc.ClientRegistrationResponse, *apiError) { + var clientMetadata oidc.ClientMetadata + if err := json.NewDecoder(r.Body).Decode(&clientMetadata); err != nil { + return nil, newAPIError(oauth2.ErrorInvalidRequest, err.Error()) + } + if err := s.ProviderConfig().Supports(clientMetadata); err != nil { + return nil, newAPIError(invalidClientMetadata, err.Error()) + } + + // metadata is guarenteed to have at least one redirect_uri by earlier validation. + id, err := oidc.GenClientID(clientMetadata.RedirectURIs[0].Host) + if err != nil { + log.Errorf("Faild to create client ID: %v", err) + return nil, newAPIError(oauth2.ErrorServerError, "unable to save client metadata") + } + + creds, err := s.ClientIdentityRepo.New(id, clientMetadata) + if err != nil { + log.Errorf("Failed to create new client identity: %v", err) + return nil, newAPIError(oauth2.ErrorServerError, "unable to save client metadata") + } + + return &oidc.ClientRegistrationResponse{ + ClientID: creds.ID, + ClientSecret: creds.Secret, + ClientMetadata: clientMetadata, + }, nil +} diff --git a/server/client_registration_test.go b/server/client_registration_test.go new file mode 100644 index 00000000..2dc647cc --- /dev/null +++ b/server/client_registration_test.go @@ -0,0 +1,161 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/coreos/go-oidc/oauth2" + "github.com/coreos/go-oidc/oidc" + "github.com/kylelemons/godebug/pretty" +) + +func TestClientRegistration(t *testing.T) { + tests := []struct { + body string + code int + }{ + {"", http.StatusBadRequest}, + { + `{ + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ] + }`, + http.StatusCreated, + }, + { + // Requesting unsupported client metadata fields (user_info_encrypted). + `{ + "application_type": "web", + "redirect_uris":[ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "client_name": "My Example", + "logo_uri": "https://client.example.org/logo.png", + "subject_type": "pairwise", + "sector_identifier_uri": "https://other.example.net/file_of_redirect_uris.json", + "token_endpoint_auth_method": "client_secret_basic", + "jwks_uri": "https://client.example.org/my_public_keys.jwks", + "userinfo_encrypted_response_alg": "RSA1_5", + "userinfo_encrypted_response_enc": "A128CBC-HS256", + "contacts": ["ve7jtb@example.org", "mary@example.org"], + "request_uris": [ + "https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA" ] + }`, + http.StatusBadRequest, + }, + { + `{ + "application_type": "web", + "redirect_uris":[ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "client_name": "My Example", + "logo_uri": "https://client.example.org/logo.png", + "subject_type": "pairwise", + "sector_identifier_uri": "https://other.example.net/file_of_redirect_uris.json", + "token_endpoint_auth_method": "client_secret_basic", + "jwks_uri": "https://client.example.org/my_public_keys.jwks", + "contacts": ["ve7jtb@example.org", "mary@example.org"], + "request_uris": [ + "https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA" ] + }`, + http.StatusCreated, + }, + } + + var handler http.Handler + f := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + }) + testServer := httptest.NewServer(f) + + issuerURL, err := url.Parse(testServer.URL) + if err != nil { + t.Fatal(err) + } + defer testServer.Close() + + for i, tt := range tests { + fixtures, err := makeTestFixtures() + if err != nil { + t.Fatal(err) + } + fixtures.srv.IssuerURL = *issuerURL + fixtures.srv.EnableClientRegistration = true + + handler = fixtures.srv.HTTPHandler() + + err = func() error { + // GET provider config through discovery URL. + resp, err := http.Get(testServer.URL + "/.well-known/openid-configuration") + if err != nil { + return fmt.Errorf("GET config: %v", err) + } + var cfg oidc.ProviderConfig + err = json.NewDecoder(resp.Body).Decode(&cfg) + resp.Body.Close() + if err != nil { + return fmt.Errorf("decode resp: %v", err) + } + + if cfg.RegistrationEndpoint == nil { + return errors.New("registration endpoint not available") + } + + // POST registration request to registration endpoint. + body := strings.NewReader(tt.body) + resp, err = http.Post(cfg.RegistrationEndpoint.String(), "application/json", body) + if err != nil { + return fmt.Errorf("POSTing client metadata: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != tt.code { + return fmt.Errorf("expected status code=%d, got=%d", tt.code, resp.StatusCode) + } + + if resp.StatusCode != http.StatusCreated { + var oauthErr oauth2.Error + if err := json.NewDecoder(resp.Body).Decode(&oauthErr); err != nil { + return fmt.Errorf("failed to decode oauth2 error: %v", err) + } + if oauthErr.Type == "" { + return fmt.Errorf("got oauth2 error with no 'error' field") + } + return nil + } + + // Read registration response. + var r oidc.ClientRegistrationResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decode response: %v", err) + } + if r.ClientID == "" { + return fmt.Errorf("no client id in registration response") + } + + metadata, err := fixtures.clientIdentityRepo.Metadata(r.ClientID) + if err != nil { + return fmt.Errorf("failed to lookup client id after creation") + } + + if diff := pretty.Compare(&metadata, &r.ClientMetadata); diff != "" { + return fmt.Errorf("metadata in response did not match metadata in db: %s", diff) + } + + return nil + }() + if err != nil { + t.Errorf("case %d: %v", i, err) + } + } +} diff --git a/server/config.go b/server/config.go index f70065a3..dfe1f0b0 100644 --- a/server/config.go +++ b/server/config.go @@ -26,15 +26,16 @@ import ( ) type ServerConfig struct { - IssuerURL string - IssuerName string - IssuerLogoURL string - TemplateDir string - EmailTemplateDirs []string - EmailFromAddress string - EmailerConfigFile string - StateConfig StateConfigurer - EnableRegistration bool + IssuerURL string + IssuerName string + IssuerLogoURL string + TemplateDir string + EmailTemplateDirs []string + EmailFromAddress string + EmailerConfigFile string + StateConfig StateConfigurer + EnableRegistration bool + EnableClientRegistration bool } type StateConfigurer interface { @@ -73,7 +74,8 @@ func (cfg *ServerConfig) Server() (*Server, error) { HealthChecks: []health.Checkable{km}, Connectors: []connector.Connector{}, - EnableRegistration: cfg.EnableRegistration, + EnableRegistration: cfg.EnableRegistration, + EnableClientRegistration: cfg.EnableClientRegistration, } err = cfg.StateConfig.Configure(&srv) diff --git a/server/http.go b/server/http.go index c2855269..19b8f8c6 100644 --- a/server/http.go +++ b/server/http.go @@ -29,19 +29,20 @@ const ( ) var ( - httpPathDiscovery = "/.well-known/openid-configuration" - httpPathToken = "/token" - httpPathKeys = "/keys" - httpPathAuth = "/auth" - httpPathHealth = "/health" - httpPathAPI = "/api" - httpPathRegister = "/register" - httpPathEmailVerify = "/verify-email" - httpPathVerifyEmailResend = "/resend-verify-email" - httpPathSendResetPassword = "/send-reset-password" - httpPathResetPassword = "/reset-password" - httpPathAcceptInvitation = "/accept-invitation" - httpPathDebugVars = "/debug/vars" + httpPathDiscovery = "/.well-known/openid-configuration" + httpPathToken = "/token" + httpPathKeys = "/keys" + httpPathAuth = "/auth" + httpPathHealth = "/health" + httpPathAPI = "/api" + httpPathRegister = "/register" + httpPathEmailVerify = "/verify-email" + httpPathVerifyEmailResend = "/resend-verify-email" + httpPathSendResetPassword = "/send-reset-password" + httpPathResetPassword = "/reset-password" + httpPathAcceptInvitation = "/accept-invitation" + httpPathDebugVars = "/debug/vars" + httpPathClientRegistration = "/registration" cookieLastSeen = "LastSeen" cookieShowEmailVerifiedMessage = "ShowEmailVerifiedMessage" diff --git a/server/server.go b/server/server.go index b8206397..f9a81259 100644 --- a/server/server.go +++ b/server/server.go @@ -74,6 +74,7 @@ type Server struct { RefreshTokenRepo refresh.RefreshTokenRepo UserEmailer *useremail.UserEmailer EnableRegistration bool + EnableClientRegistration bool localConnectorID string } @@ -110,19 +111,15 @@ func (s *Server) KillSession(sessionKey string) error { return err } -func (s *Server) pathURL(path string) *url.URL { - u := s.IssuerURL - u.Path = path - return &u -} - func (s *Server) ProviderConfig() oidc.ProviderConfig { + authEndpoint := s.absURL(httpPathAuth) + tokenEndpoint := s.absURL(httpPathToken) + keysEndpoint := s.absURL(httpPathKeys) cfg := oidc.ProviderConfig{ - Issuer: &s.IssuerURL, - - AuthEndpoint: s.pathURL(httpPathAuth), - TokenEndpoint: s.pathURL(httpPathToken), - KeysEndpoint: s.pathURL(httpPathKeys), + Issuer: &s.IssuerURL, + AuthEndpoint: &authEndpoint, + TokenEndpoint: &tokenEndpoint, + KeysEndpoint: &keysEndpoint, GrantTypesSupported: []string{oauth2.GrantTypeAuthCode, oauth2.GrantTypeClientCreds}, ResponseTypesSupported: []string{"code"}, @@ -131,6 +128,11 @@ func (s *Server) ProviderConfig() oidc.ProviderConfig { TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"}, } + if s.EnableClientRegistration { + regEndpoint := s.absURL(httpPathClientRegistration) + cfg.RegistrationEndpoint = ®Endpoint + } + return cfg } @@ -246,6 +248,10 @@ func (s *Server) HTTPHandler() http.Handler { redirectValidityWindow: s.SessionManager.ValidityWindow, }) + if s.EnableClientRegistration { + mux.HandleFunc(httpPathClientRegistration, s.handleClientRegistration) + } + mux.HandleFunc(httpPathDebugVars, health.ExpvarHandler) pcfg := s.ProviderConfig()