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
This commit is contained in:
Vladislav Klimenko 2020-04-10 20:19:12 +03:00
Родитель bf48bf16ab
Коммит d7afaacd4c
2 изменённых файлов: 436 добавлений и 0 удалений

259
dcrp/dcrp.go Normal file
Просмотреть файл

@ -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
}

177
dcrp/dcrp_test.go Normal file
Просмотреть файл

@ -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)
}
}