Skip to content

Commit 294e1f3

Browse files
internal/oauthex: add dynamic client registration (#519)
This CL implements https://www.rfc-editor.org/rfc/rfc7591.html See https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/client/auth.ts as a reference. For #493
1 parent ab09251 commit 294e1f3

File tree

3 files changed

+418
-0
lines changed

3 files changed

+418
-0
lines changed

internal/oauthex/auth_meta.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
package oauthex
99

1010
import (
11+
"bytes"
1112
"context"
13+
"encoding/json"
1214
"errors"
1315
"fmt"
16+
"io"
1417
"net/http"
18+
"time"
1519
)
1620

1721
// AuthServerMeta represents the metadata for an OAuth 2.0 authorization server,
@@ -109,6 +113,153 @@ type AuthServerMeta struct {
109113
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
110114
}
111115

116+
// ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591).
117+
type ClientRegistrationMetadata struct {
118+
// RedirectURIs is a REQUIRED JSON array of redirection URI strings for use in
119+
// redirect-based flows (such as the authorization code grant).
120+
RedirectURIs []string `json:"redirect_uris"`
121+
122+
// TokenEndpointAuthMethod is an OPTIONAL string indicator of the requested
123+
// authentication method for the token endpoint.
124+
// If omitted, the default is "client_secret_basic".
125+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
126+
127+
// GrantTypes is an OPTIONAL JSON array of OAuth 2.0 grant type strings
128+
// that the client will restrict itself to using.
129+
// If omitted, the default is ["authorization_code"].
130+
GrantTypes []string `json:"grant_types,omitempty"`
131+
132+
// ResponseTypes is an OPTIONAL JSON array of OAuth 2.0 response type strings
133+
// that the client will restrict itself to using.
134+
// If omitted, the default is ["code"].
135+
ResponseTypes []string `json:"response_types,omitempty"`
136+
137+
// ClientName is a RECOMMENDED human-readable name of the client to be presented
138+
// to the end-user.
139+
ClientName string `json:"client_name,omitempty"`
140+
141+
// ClientURI is a RECOMMENDED URL of a web page providing information about the client.
142+
ClientURI string `json:"client_uri,omitempty"`
143+
144+
// LogoURI is an OPTIONAL URL of a logo for the client, which may be displayed
145+
// to the end-user.
146+
LogoURI string `json:"logo_uri,omitempty"`
147+
148+
// Scope is an OPTIONAL string containing a space-separated list of scope values
149+
// that the client will restrict itself to using.
150+
Scope string `json:"scope,omitempty"`
151+
152+
// Contacts is an OPTIONAL JSON array of strings representing ways to contact
153+
// people responsible for this client (e.g., email addresses).
154+
Contacts []string `json:"contacts,omitempty"`
155+
156+
// TOSURI is an OPTIONAL URL that the client provides to the end-user
157+
// to read about the client's terms of service.
158+
TOSURI string `json:"tos_uri,omitempty"`
159+
160+
// PolicyURI is an OPTIONAL URL that the client provides to the end-user
161+
// to read about the client's privacy policy.
162+
PolicyURI string `json:"policy_uri,omitempty"`
163+
164+
// JWKSURI is an OPTIONAL URL for the client's JSON Web Key Set [JWK] document.
165+
// This is preferred over the 'jwks' parameter.
166+
JWKSURI string `json:"jwks_uri,omitempty"`
167+
168+
// JWKS is an OPTIONAL client's JSON Web Key Set [JWK] document, passed by value.
169+
// This is an alternative to providing a JWKSURI.
170+
JWKS string `json:"jwks,omitempty"`
171+
172+
// SoftwareID is an OPTIONAL unique identifier string for the client software,
173+
// constant across all instances and versions.
174+
SoftwareID string `json:"software_id,omitempty"`
175+
176+
// SoftwareVersion is an OPTIONAL version identifier string for the client software.
177+
SoftwareVersion string `json:"software_version,omitempty"`
178+
179+
// SoftwareStatement is an OPTIONAL JWT that asserts client metadata values.
180+
// Values in the software statement take precedence over other metadata values.
181+
SoftwareStatement string `json:"software_statement,omitempty"`
182+
}
183+
184+
// ClientRegistrationResponse represents the fields returned by the Authorization Server
185+
// (RFC 7591, Section 3.2.1 and 3.2.2).
186+
type ClientRegistrationResponse struct {
187+
// ClientRegistrationMetadata contains all registered client metadata, returned by the
188+
// server on success, potentially with modified or defaulted values.
189+
ClientRegistrationMetadata
190+
191+
// ClientID is the REQUIRED newly issued OAuth 2.0 client identifier.
192+
ClientID string `json:"client_id"`
193+
194+
// ClientSecret is an OPTIONAL client secret string.
195+
ClientSecret string `json:"client_secret,omitempty"`
196+
197+
// ClientIDIssuedAt is an OPTIONAL Unix timestamp when the ClientID was issued.
198+
ClientIDIssuedAt time.Time `json:"client_id_issued_at,omitempty"`
199+
200+
// ClientSecretExpiresAt is the REQUIRED (if client_secret is issued) Unix
201+
// timestamp when the secret expires, or 0 if it never expires.
202+
ClientSecretExpiresAt time.Time `json:"client_secret_expires_at,omitempty"`
203+
}
204+
205+
func (r *ClientRegistrationResponse) MarshalJSON() ([]byte, error) {
206+
type alias ClientRegistrationResponse
207+
var clientIDIssuedAt int64
208+
var clientSecretExpiresAt int64
209+
210+
if !r.ClientIDIssuedAt.IsZero() {
211+
clientIDIssuedAt = r.ClientIDIssuedAt.Unix()
212+
}
213+
if !r.ClientSecretExpiresAt.IsZero() {
214+
clientSecretExpiresAt = r.ClientSecretExpiresAt.Unix()
215+
}
216+
217+
return json.Marshal(&struct {
218+
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
219+
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
220+
*alias
221+
}{
222+
ClientIDIssuedAt: clientIDIssuedAt,
223+
ClientSecretExpiresAt: clientSecretExpiresAt,
224+
alias: (*alias)(r),
225+
})
226+
}
227+
228+
func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error {
229+
type alias ClientRegistrationResponse
230+
aux := &struct {
231+
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
232+
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
233+
*alias
234+
}{
235+
alias: (*alias)(r),
236+
}
237+
if err := json.Unmarshal(data, &aux); err != nil {
238+
return err
239+
}
240+
if aux.ClientIDIssuedAt != 0 {
241+
r.ClientIDIssuedAt = time.Unix(aux.ClientIDIssuedAt, 0)
242+
}
243+
if aux.ClientSecretExpiresAt != 0 {
244+
r.ClientSecretExpiresAt = time.Unix(aux.ClientSecretExpiresAt, 0)
245+
}
246+
return nil
247+
}
248+
249+
// ClientRegistrationError is the error response from the Authorization Server
250+
// for a failed registration attempt (RFC 7591, Section 3.2.2).
251+
type ClientRegistrationError struct {
252+
// ErrorCode is the REQUIRED error code if registration failed (RFC 7591, 3.2.2).
253+
ErrorCode string `json:"error"`
254+
255+
// ErrorDescription is an OPTIONAL human-readable error message.
256+
ErrorDescription string `json:"error_description,omitempty"`
257+
}
258+
259+
func (e *ClientRegistrationError) Error() string {
260+
return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription)
261+
}
262+
112263
var wellKnownPaths = []string{
113264
"/.well-known/oauth-authorization-server",
114265
"/.well-known/openid-configuration",
@@ -143,3 +294,59 @@ func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*
143294
}
144295
return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...))
145296
}
297+
298+
// RegisterClient performs Dynamic Client Registration according to RFC 7591.
299+
func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) {
300+
if registrationEndpoint == "" {
301+
return nil, fmt.Errorf("registration_endpoint is required")
302+
}
303+
304+
if c == nil {
305+
c = http.DefaultClient
306+
}
307+
308+
payload, err := json.Marshal(clientMeta)
309+
if err != nil {
310+
return nil, fmt.Errorf("failed to marshal client metadata: %w", err)
311+
}
312+
313+
req, err := http.NewRequestWithContext(ctx, "POST", registrationEndpoint, bytes.NewBuffer(payload))
314+
if err != nil {
315+
return nil, fmt.Errorf("failed to create registration request: %w", err)
316+
}
317+
318+
req.Header.Set("Content-Type", "application/json")
319+
req.Header.Set("Accept", "application/json")
320+
321+
resp, err := c.Do(req)
322+
if err != nil {
323+
return nil, fmt.Errorf("registration request failed: %w", err)
324+
}
325+
defer resp.Body.Close()
326+
327+
body, err := io.ReadAll(resp.Body)
328+
if err != nil {
329+
return nil, fmt.Errorf("failed to read registration response body: %w", err)
330+
}
331+
332+
if resp.StatusCode == http.StatusCreated {
333+
var regResponse ClientRegistrationResponse
334+
if err := json.Unmarshal(body, &regResponse); err != nil {
335+
return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body))
336+
}
337+
if regResponse.ClientID == "" {
338+
return nil, fmt.Errorf("registration response is missing required 'client_id' field")
339+
}
340+
return &regResponse, nil
341+
}
342+
343+
if resp.StatusCode == http.StatusBadRequest {
344+
var regError ClientRegistrationError
345+
if err := json.Unmarshal(body, &regError); err != nil {
346+
return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body))
347+
}
348+
return nil, &regError
349+
}
350+
351+
return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body))
352+
}

0 commit comments

Comments
 (0)