From d7afaacd4cbe314c782cb5e96aab1b9b533cc8f1 Mon Sep 17 00:00:00 2001 From: Vladislav Klimenko Date: Fri, 10 Apr 2020 20:19:12 +0300 Subject: [PATCH] oauth2: initial implementation of OAuth 2.0 Dynamic Client Registration Protocol There is no OAuth 2.0 Dynamic Client Registration Protocol implementation available in oauth2. The protocol is a PROPOSED STANDARD, however, many OAuth servers already support it. Dynamic Client Registration Protocol is described at https://tools.ietf.org/html/rfc7591 --- dcrp/dcrp.go | 259 ++++++++++++++++++++++++++++++++++++++++++++++ dcrp/dcrp_test.go | 177 +++++++++++++++++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 dcrp/dcrp.go create mode 100644 dcrp/dcrp_test.go diff --git a/dcrp/dcrp.go b/dcrp/dcrp.go new file mode 100644 index 000000000..0767f3cab --- /dev/null +++ b/dcrp/dcrp.go @@ -0,0 +1,259 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package dcrp implements the OAuth 2.0 Dynamic Client Registration Protocol. +// This specification defines mechanisms for dynamically registering OAuth 2.0 clients with authorization servers. +// +// See https://tools.ietf.org/html/rfc7591 + +package dcrp // import "golang.org/x/oauth2/dcrp" + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" +) + +// Config describes Dynamic Client Registration configuration +type Config struct { + // InitialAccessToken specifies access token used to get access to get access to + // client registration endpoint URL. The method by which the initial access token + // is obtained by the client or developer is generally out of band + InitialAccessToken string + + // ClientRegistrationEndpointURL specifies authorization server's client registration endpoint URL + // This is a constant specific to each server. + ClientRegistrationEndpointURL string + + // Metadata specifies client metadata to be used for client registration + Metadata +} + +// Metadata describes client metadata. +// Registered clients have a set of metadata values associated with their +// client identifier at an authorization server. The implementation +// and use of all client metadata fields is OPTIONAL +type Metadata struct { + // RedirectURIs specifies redirection URI strings for use in + // redirect-based flows such as the "authorization code" and "implicit". + RedirectURIs []string `json:"redirect_uris,omitempty"` + + // TokenEndpointAuthMethod specifies indicator of the requested authentication + // method for the token endpoint + // Possible values are: + // "none": The client is a public client and does not have a client secret. + // "client_secret_post": The client uses the HTTP POST parameters + // "client_secret_basic": The client uses HTTP Basic + // Additional values can be defined or absolute URIs can also be used + // as values for this parameter without being registered. + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + + // GrantTypes specifies grant type strings that the client can use at the token endpoint + // Possible values are: + // "authorization_code": The authorization code grant type + // "implicit": The implicit grant type + // "password": The resource owner password credentials grant type + // "client_credentials": The client credentials grant type + // "refresh_token": The refresh token grant type + // "urn:ietf:params:oauth:grant-type:jwt-bearer": The JWT Bearer Token Grant Type + // "urn:ietf:params:oauth:grant-type:saml2-bearer": The SAML 2.0 Bearer Assertion Grant + GrantTypes []string `json:"grant_types,omitempty"` + + // ResponseTypes specifies response type strings that the client can + // use at the authorization endpoint. + // Possible values are: + // "code": The "authorization code" response + // "token": The "implicit" response + ResponseTypes []string `json:"response_types,omitempty"` + + // ClientName specifies Human-readable string name of the client + // to be presented to the end-user during authorization + ClientName string `json:"client_name,omitempty"` + + // ClientURI specifies URL of a web page providing information about the client. + ClientURI string `json:"client_uri,omitempty"` + + // LogoURI specifies URL of a logo of the client + LogoURI string `json:"logo_uri,omitempty"` + + // Scopes specifies scope values that the client can use when requesting access tokens. + Scopes []string `json:"-"` + + // Scope specifies wire-level scopes representation + Scope string `json:"scope,omitempty"` + + // Contacts specifies ways to contact people responsible for this client, + // typically email addresses. + Contacts []string `json:"contacts,omitempty"` + + // TermsOfServiceURI specifies URL of a human-readable terms of service + // document for the client + TermsOfServiceURI string `json:"tos_uri,omitempty"` + + // PolicyURI specifies URL of a human-readable privacy policy document + PolicyURI string `json:"policy_uri,omitempty"` + + // JWKSURI specifies URL referencing the client's JWK Set [RFC7517] document, + // which contains the client's public keys. + JWKSURI string `json:"jwks_uri,omitempty"` + + // JWKS specifies the client's JWK Set [RFC7517] document, which contains + // the client's public keys. The value of this field MUST be a JSON + // containing a valid JWK Set. + JWKS string `json:"jwks,omitempty"` + + // SoftwareID specifies UUID assigned by the client developer or software publisher + // used by registration endpoints to identify the client software. + SoftwareID string `json:"software_id,omitempty"` + + // SoftwareVersion specifies version of the client software + SoftwareVersion string `json:"software_version,omitempty"` + + // SoftwareStatement specifies client metadata values about the client software + // as claims. This is a string value containing the entire signed JWT. + SoftwareStatement string `json:"software_statement,omitempty"` + + // Optional specifies optional fields + Optional map[string]string `json:"-"` +} + +// prepareForWire prepares Metadata struct to be ready to sent to server. +func (md *Metadata) prepareForWire() { + md.Scope = strings.Join(md.Scopes, " ") +} + +// prepareFromWire prepares Metadata to be ready to be used by user +func (md *Metadata) prepareFromWire() { + md.Scopes = strings.Split(md.Scope, " ") +} + +// Response describes Client Information Response as specified in Section 3.2.1 of RFC 7591 +type Response struct { + // ClientID specifies client identifier string. REQUIRED + ClientID string `json:"client_id"` + + // ClientSecret specifies client secret string. OPTIONAL + ClientSecret string `json:"client_secret"` + + // ClientIDIssuedAt specifies time at which the client identifier was issued. OPTIONAL + ClientIDIssuedAt time.Time `json:"client_id_issued_at"` + + // ClientSecretExpiresAt specifies time at which the client secret will expire + // or 0 if it will not expire. REQUIRED if "client_secret" is issued. + ClientSecretExpiresAt time.Time `json:"client_secret_expires_at"` + + // Additionally, the authorization server MUST return all registered metadata about this client + Metadata `json:",inline"` +} + +// Register performs Dynamic Client Registration dy doing round trip to authorization server +func (c *Config) Register() (*Response, error) { + c.Metadata.prepareForWire() + jsonMetadata, err := json.Marshal(c.Metadata) + if err != nil { + return nil, err + } + req, err := newHTTPRequest(c.ClientRegistrationEndpointURL, c.InitialAccessToken, jsonMetadata) + if err != nil { + return nil, err + } + return doRoundTrip(req) +} + +// RegistrationError describes errors returned by auth server during client registration process +type RegistrationError struct { + Response *http.Response + Body []byte +} + +func (r *RegistrationError) Error() string { + return fmt.Sprintf("oauth2: cannot register client: %v\nResponse: %s", r.Response.Status, r.Body) +} + +// newHTTPRequest returns a new *http.Request to be used for client registration +// It has header fields specified +func newHTTPRequest(registrationURL, initialAccessToken string, body []byte) (*http.Request, error) { + req, err := http.NewRequest("POST", registrationURL, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if initialAccessToken != "" { + req.Header.Set("Authorization", "Bearer "+initialAccessToken) + } + return req, nil +} + +// doRoundTrip performs communication with authorization server for client registration +func doRoundTrip(req *http.Request) (*Response, error) { + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("oauth2 dcrp: cannot read server response: %v", err) + } + // The server responds with an HTTP 201 Created status code and a body of type "application/json" + if code := resp.StatusCode; code != 201 { + return nil, &RegistrationError{ + Response: resp, + Body: body, + } + } + + // The response contains the client identifier as well as the client secret, + // if the client is a confidential client. + // The response MAY contain additional fields + cr := &Response{} + if err = json.Unmarshal(body, cr); err != nil { + return nil, err + } + cr.Metadata.prepareFromWire() + if cr.ClientID == "" { + return nil, errors.New("oauth2 dcrp: server response missing required client_id in body:\n" + string(body)) + } + return cr, nil +} + +// MarshalJSON prepares Response for wire JSON representation +func (r Response) MarshalJSON() ([]byte, error) { + type Alias Response + wire := struct { + ClientIDIssuedAt int64 `json:"client_id_issued_at"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at"` + Alias + }{ + ClientIDIssuedAt: r.ClientIDIssuedAt.Unix(), + ClientSecretExpiresAt: r.ClientSecretExpiresAt.Unix(), + Alias: (Alias)(r), + } + return json.Marshal(wire) +} + +// MarshalJSON prepares Response from wire JSON representation +func (r *Response) UnmarshalJSON(data []byte) error { + type Alias Response + wire := &struct { + ClientIDIssuedAt int64 `json:"client_id_issued_at"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at"` + *Alias + }{ + Alias: (*Alias)(r), + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + r.ClientIDIssuedAt = time.Unix(wire.ClientIDIssuedAt, 0) + r.ClientSecretExpiresAt = time.Unix(wire.ClientSecretExpiresAt, 0) + return nil +} diff --git a/dcrp/dcrp_test.go b/dcrp/dcrp_test.go new file mode 100644 index 000000000..0686770dc --- /dev/null +++ b/dcrp/dcrp_test.go @@ -0,0 +1,177 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package dcrp + +import ( + "encoding/json" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +var ( + newClientMetadata = Metadata{ + RedirectURIs: []string{ + "https://redirect1.example.com", + "https://redirect2.example.com", + "https://redirect3.example.com", + }, + TokenEndpointAuthMethod: "client_secret_basic", + GrantTypes: []string{ + "client_credentials", + }, + ResponseTypes: []string{ + "token", + }, + ClientName: "test client", + ClientURI: "https://testclient.example.com", + LogoURI: "https://testclient.example.com/logo.png", + Scopes: []string{ + "email", + "profile", + }, + Contacts: []string{ + "email1@example.com", + "email2@example.com", + }, + TermsOfServiceURI: "https://testclent.example.com/tos.txt", + PolicyURI: "https://testclient.example.com/policy.txt", + JWKSURI: "https://testclient.example.com/jwks.json", + JWKS: "public keys go here", + SoftwareID: "01234567-0123-0123-0123-01234567890a", + SoftwareVersion: "1", + SoftwareStatement: "statement", + } + + wantClientRegistrationRequestJSON = `{ + "redirect_uris": [ + "https://redirect1.example.com", + "https://redirect2.example.com", + "https://redirect3.example.com" + ], + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": [ + "client_credentials" + ], + "response_types": [ + "token" + ], + "client_name": "test client", + "client_uri": "https://testclient.example.com", + "logo_uri": "https://testclient.example.com/logo.png", + "scope": "email profile", + "contacts": [ + "email1@example.com", + "email2@example.com" + ], + "tos_uri": "https://testclent.example.com/tos.txt", + "policy_uri": "https://testclient.example.com/policy.txt", + "jwks_uri": "https://testclient.example.com/jwks.json", + "jwks": "public keys go here", + "software_id": "01234567-0123-0123-0123-01234567890a", + "software_version": "1", + "software_statement": "statement" +}` + + wantClientID = "ASD123" + wantClientIDIssuedAt = time.Unix(time.Now().Unix(), 0) +) + +func newConf(endpoint string) *Config { + return &Config{ + InitialAccessToken: "123", + ClientRegistrationEndpointURL: endpoint, + Metadata: newClientMetadata, + } +} + +// jsonEqual compares the JSON in two byte slices. +func jsonEqual(a, b []byte) (bool, error) { + var json1, json2 interface{} + if err := json.Unmarshal(a, &json1); err != nil { + return false, err + } + if err := json.Unmarshal(b, &json2); err != nil { + return false, err + } + return reflect.DeepEqual(json1, json2), nil +} + +// metadataEqual compares two items of metadata, ignoring wire scope data. +func metadataEqual(a, b Metadata) (bool, error) { + a.Scope = "" + b.Scope = "" + return reflect.DeepEqual(a, b), nil +} + +func TestDynamicClientRegistration(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() != "/client-registration" { + t.Errorf("Unexpected URL: %q", r.URL) + } + headerAuth := r.Header.Get("Authorization") + if headerAuth != "" { + if !strings.HasPrefix(headerAuth, "Bearer ") { + t.Errorf("Unexpected authorization header, %v is found.", headerAuth) + } + } + headerContentType := r.Header.Get("Content-Type") + if got, want := headerContentType, "application/json"; got != want { + t.Errorf("Content-Type = %q; want %q", got, want) + } + body, err := ioutil.ReadAll(r.Body) + if err != nil { + r.Body.Close() + } + if err != nil { + t.Errorf("failed reading request body: %s.", err) + } + // Check wire JSON data representation is as expected + equal, err := jsonEqual(body, []byte(wantClientRegistrationRequestJSON)) + if !equal { + t.Errorf("Unexpected dynamic client registration protocol payload.\ngot: %s\nwant: %s\n", body, wantClientRegistrationRequestJSON) + } + var md Metadata + err = json.Unmarshal(body, &md) + if err != nil { + t.Errorf("Unexpected dynamic client registration protocol payload.\n%s\nError: %v", body, err) + } + + // Prepare Response with registered client data + clientInfo := Response{} + clientInfo.Metadata = md + clientInfo.ClientID = wantClientID + clientInfo.ClientIDIssuedAt = wantClientIDIssuedAt + resp, err := json.Marshal(clientInfo) + if err != nil { + t.Errorf("Unable to marshal Response\nError: %v", err) + } + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, string(resp)) + })) + defer ts.Close() + + conf := newConf(ts.URL + "/client-registration") + resp, err := conf.Register() + if err != nil { + t.Error(err) + } + if resp.ClientID != wantClientID { + t.Errorf("Unable to register client. Incorrect ClientID\ngot=%s\nwant=%s\n", resp.ClientID, wantClientID) + } + if resp.ClientIDIssuedAt != wantClientIDIssuedAt { + t.Errorf("Unable to register client. Incorrect ClientIDIssuedAt\ngot=%s\nwant=%s\n", resp.ClientIDIssuedAt, wantClientIDIssuedAt) + } + equal, err := metadataEqual(newClientMetadata, resp.Metadata) + if !equal { + t.Errorf("Unexpected dynamic client registration protocol metadata returned.\ngot=%v\nwant=%v\n", resp.Metadata, newClientMetadata) + } +}