Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions internal/oauthex/auth_meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
package oauthex

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)

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

// ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591).
type ClientRegistrationMetadata struct {
// RedirectURIs is a REQUIRED JSON array of redirection URI strings for use in
// redirect-based flows (such as the authorization code grant).
RedirectURIs []string `json:"redirect_uris"`

// TokenEndpointAuthMethod is an OPTIONAL string indicator of the requested
// authentication method for the token endpoint.
// If omitted, the default is "client_secret_basic".
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`

// GrantTypes is an OPTIONAL JSON array of OAuth 2.0 grant type strings
// that the client will restrict itself to using.
// If omitted, the default is ["authorization_code"].
GrantTypes []string `json:"grant_types,omitempty"`

// ResponseTypes is an OPTIONAL JSON array of OAuth 2.0 response type strings
// that the client will restrict itself to using.
// If omitted, the default is ["code"].
ResponseTypes []string `json:"response_types,omitempty"`

// ClientName is a RECOMMENDED human-readable name of the client to be presented
// to the end-user.
ClientName string `json:"client_name,omitempty"`

// ClientURI is a RECOMMENDED URL of a web page providing information about the client.
ClientURI string `json:"client_uri,omitempty"`

// LogoURI is an OPTIONAL URL of a logo for the client, which may be displayed
// to the end-user.
LogoURI string `json:"logo_uri,omitempty"`

// Scope is an OPTIONAL string containing a space-separated list of scope values
// that the client will restrict itself to using.
Scope string `json:"scope,omitempty"`

// Contacts is an OPTIONAL JSON array of strings representing ways to contact
// people responsible for this client (e.g., email addresses).
Contacts []string `json:"contacts,omitempty"`

// TOSURI is an OPTIONAL URL that the client provides to the end-user
// to read about the client's terms of service.
TOSURI string `json:"tos_uri,omitempty"`

// PolicyURI is an OPTIONAL URL that the client provides to the end-user
// to read about the client's privacy policy.
PolicyURI string `json:"policy_uri,omitempty"`

// JWKSURI is an OPTIONAL URL for the client's JSON Web Key Set [JWK] document.
// This is preferred over the 'jwks' parameter.
JWKSURI string `json:"jwks_uri,omitempty"`

// JWKS is an OPTIONAL client's JSON Web Key Set [JWK] document, passed by value.
// This is an alternative to providing a JWKSURI.
JWKS string `json:"jwks,omitempty"`

// SoftwareID is an OPTIONAL unique identifier string for the client software,
// constant across all instances and versions.
SoftwareID string `json:"software_id,omitempty"`

// SoftwareVersion is an OPTIONAL version identifier string for the client software.
SoftwareVersion string `json:"software_version,omitempty"`

// SoftwareStatement is an OPTIONAL JWT that asserts client metadata values.
// Values in the software statement take precedence over other metadata values.
SoftwareStatement string `json:"software_statement,omitempty"`
}

// ClientRegistrationResponse represents the fields returned by the Authorization Server
// (RFC 7591, Section 3.2.1 and 3.2.2).
type ClientRegistrationResponse struct {
// ClientRegistrationMetadata contains all registered client metadata, returned by the
// server on success, potentially with modified or defaulted values.
ClientRegistrationMetadata

// ClientID is the REQUIRED newly issued OAuth 2.0 client identifier.
ClientID string `json:"client_id"`

// ClientSecret is an OPTIONAL client secret string.
ClientSecret string `json:"client_secret,omitempty"`

// ClientIDIssuedAt is an OPTIONAL Unix timestamp when the ClientID was issued.
ClientIDIssuedAt time.Time `json:"client_id_issued_at,omitempty"`

// ClientSecretExpiresAt is the REQUIRED (if client_secret is issued) Unix
// timestamp when the secret expires, or 0 if it never expires.
ClientSecretExpiresAt time.Time `json:"client_secret_expires_at,omitempty"`
}

func (r ClientRegistrationResponse) MarshalJSON() ([]byte, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*Client...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

type Alias ClientRegistrationResponse
var clientIDIssuedAt int64
var clientSecretExpiresAt int64

if !r.ClientIDIssuedAt.IsZero() {
clientIDIssuedAt = r.ClientIDIssuedAt.Unix()
}
if !r.ClientSecretExpiresAt.IsZero() {
clientSecretExpiresAt = r.ClientSecretExpiresAt.Unix()
}

return json.Marshal(&struct {
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
*Alias
}{
ClientIDIssuedAt: clientIDIssuedAt,
ClientSecretExpiresAt: clientSecretExpiresAt,
Alias: (*Alias)(&r),
})
}

func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error {
type Alias ClientRegistrationResponse
aux := &struct {
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
*Alias
}{
Alias: (*Alias)(r),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if aux.ClientIDIssuedAt != 0 {
r.ClientIDIssuedAt = time.Unix(aux.ClientIDIssuedAt, 0)
}
if aux.ClientSecretExpiresAt != 0 {
r.ClientSecretExpiresAt = time.Unix(aux.ClientSecretExpiresAt, 0)
}
return nil
}

// ClientRegistrationError is the error response from the Authorization Server
// for a failed registration attempt (RFC 7591, Section 3.2.2).
type ClientRegistrationError struct {
// ErrorCode is the REQUIRED error code if registration failed (RFC 7591, 3.2.2).
ErrorCode string `json:"error"`

// ErrorDescription is an OPTIONAL human-readable error message.
ErrorDescription string `json:"error_description,omitempty"`
}

func (e *ClientRegistrationError) Error() string {
return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription)
}

var wellKnownPaths = []string{
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
Expand Down Expand Up @@ -143,3 +294,59 @@ func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*
}
return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...))
}

// RegisterClient performs Dynamic Client Registration according to RFC 7591.
func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) {
if registrationEndpoint == "" {
return nil, fmt.Errorf("registration_endpoint is required")
}

if c == nil {
c = http.DefaultClient
}

payload, err := json.Marshal(clientMeta)
if err != nil {
return nil, fmt.Errorf("failed to marshal client metadata: %w", err)
}

req, err := http.NewRequestWithContext(ctx, "POST", registrationEndpoint, bytes.NewBuffer(payload))
if err != nil {
return nil, fmt.Errorf("failed to create registration request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")

resp, err := c.Do(req)
if err != nil {
return nil, fmt.Errorf("registration request failed: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read registration response body: %w", err)
}

if resp.StatusCode == http.StatusCreated {
var regResponse ClientRegistrationResponse
if err := json.Unmarshal(body, &regResponse); err != nil {
return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body))
}
if regResponse.ClientID == "" {
return nil, fmt.Errorf("registration response is missing required 'client_id' field")
}
return &regResponse, nil
}

if resp.StatusCode == http.StatusBadRequest {
var regError ClientRegistrationError
if err := json.Unmarshal(body, &regError); err != nil {
return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body))
}
return nil, &regError
}

return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body))
}
Loading