|
8 | 8 | package oauthex
|
9 | 9 |
|
10 | 10 | import (
|
| 11 | + "bytes" |
11 | 12 | "context"
|
| 13 | + "encoding/json" |
12 | 14 | "errors"
|
13 | 15 | "fmt"
|
| 16 | + "io" |
14 | 17 | "net/http"
|
| 18 | + "time" |
15 | 19 | )
|
16 | 20 |
|
17 | 21 | // AuthServerMeta represents the metadata for an OAuth 2.0 authorization server,
|
@@ -109,6 +113,153 @@ type AuthServerMeta struct {
|
109 | 113 | CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
|
110 | 114 | }
|
111 | 115 |
|
| 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 | + |
112 | 263 | var wellKnownPaths = []string{
|
113 | 264 | "/.well-known/oauth-authorization-server",
|
114 | 265 | "/.well-known/openid-configuration",
|
@@ -143,3 +294,59 @@ func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*
|
143 | 294 | }
|
144 | 295 | return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...))
|
145 | 296 | }
|
| 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, ®Response); 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 ®Response, nil |
| 341 | + } |
| 342 | + |
| 343 | + if resp.StatusCode == http.StatusBadRequest { |
| 344 | + var regError ClientRegistrationError |
| 345 | + if err := json.Unmarshal(body, ®Error); err != nil { |
| 346 | + return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body)) |
| 347 | + } |
| 348 | + return nil, ®Error |
| 349 | + } |
| 350 | + |
| 351 | + return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body)) |
| 352 | +} |
0 commit comments