Skip to content

Commit fcedca4

Browse files
committed
feat: OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591)
Implements RFC 7591 OAuth 2.0 Dynamic Client Registration Protocol, allowing clients to register dynamically at runtime via HTTP API. Features: - `POST /register` endpoint for client registration - Request/response types compliant with RFC 7591 - Support for both confidential and public clients (PKCE) - Optional authentication via initial access token - Default values per RFC spec (`grant_types`, `response_types`, etc.) - Validation of grant types and response types - Discovery endpoint includes `registration_endpoint` field Signed-off-by: ByteBaker <[email protected]>
1 parent 9207486 commit fcedca4

File tree

3 files changed

+434
-0
lines changed

3 files changed

+434
-0
lines changed

server/handlers.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type discovery struct {
8080
UserInfo string `json:"userinfo_endpoint"`
8181
DeviceEndpoint string `json:"device_authorization_endpoint"`
8282
Introspect string `json:"introspection_endpoint"`
83+
Registration string `json:"registration_endpoint"`
8384
GrantTypes []string `json:"grant_types_supported"`
8485
ResponseTypes []string `json:"response_types_supported"`
8586
Subjects []string `json:"subject_types_supported"`
@@ -114,6 +115,7 @@ func (s *Server) constructDiscovery() discovery {
114115
UserInfo: s.absURL("/userinfo"),
115116
DeviceEndpoint: s.absURL("/device/code"),
116117
Introspect: s.absURL("/token/introspect"),
118+
Registration: s.absURL("/register"),
117119
Subjects: []string{"public"},
118120
IDTokenAlgs: []string{string(jose.RS256)},
119121
CodeChallengeAlgs: []string{codeChallengeMethodS256, codeChallengeMethodPlain},
@@ -1490,3 +1492,174 @@ func usernamePrompt(conn connector.PasswordConnector) string {
14901492
}
14911493
return "Username"
14921494
}
1495+
1496+
// clientRegistrationRequest represents an RFC 7591 client registration request
1497+
type clientRegistrationRequest struct {
1498+
RedirectURIs []string `json:"redirect_uris"`
1499+
ClientName string `json:"client_name,omitempty"`
1500+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
1501+
GrantTypes []string `json:"grant_types,omitempty"`
1502+
ResponseTypes []string `json:"response_types,omitempty"`
1503+
Scope string `json:"scope,omitempty"`
1504+
LogoURI string `json:"logo_uri,omitempty"`
1505+
}
1506+
1507+
// clientRegistrationResponse represents an RFC 7591 client registration response
1508+
type clientRegistrationResponse struct {
1509+
ClientID string `json:"client_id"`
1510+
ClientSecret string `json:"client_secret,omitempty"`
1511+
ClientSecretExpiresAt int64 `json:"client_secret_expires_at"`
1512+
ClientName string `json:"client_name,omitempty"`
1513+
RedirectURIs []string `json:"redirect_uris"`
1514+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
1515+
GrantTypes []string `json:"grant_types,omitempty"`
1516+
ResponseTypes []string `json:"response_types,omitempty"`
1517+
Scope string `json:"scope,omitempty"`
1518+
LogoURI string `json:"logo_uri,omitempty"`
1519+
}
1520+
1521+
// handleClientRegistration implements RFC 7591 OAuth 2.0 Dynamic Client Registration Protocol
1522+
func (s *Server) handleClientRegistration(w http.ResponseWriter, r *http.Request) {
1523+
ctx := r.Context()
1524+
1525+
// Only POST method is allowed
1526+
if r.Method != http.MethodPost {
1527+
s.registrationErrHelper(w, errInvalidRequest, "Method not allowed", http.StatusMethodNotAllowed)
1528+
return
1529+
}
1530+
1531+
// Check Initial Access Token if configured (RFC 7591 Section 3.1)
1532+
if s.registrationToken != "" {
1533+
authHeader := r.Header.Get("Authorization")
1534+
const bearerPrefix = "Bearer "
1535+
1536+
if authHeader == "" || !strings.HasPrefix(authHeader, bearerPrefix) {
1537+
w.Header().Set("WWW-Authenticate", "Bearer")
1538+
s.registrationErrHelper(w, errInvalidRequest, "Initial access token required", http.StatusUnauthorized)
1539+
return
1540+
}
1541+
1542+
providedToken := strings.TrimPrefix(authHeader, bearerPrefix)
1543+
if providedToken != s.registrationToken {
1544+
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
1545+
s.registrationErrHelper(w, errInvalidRequest, "Invalid initial access token", http.StatusUnauthorized)
1546+
return
1547+
}
1548+
1549+
s.logger.InfoContext(ctx, "client registration authenticated with initial access token")
1550+
} else {
1551+
s.logger.WarnContext(ctx, "client registration endpoint is open - no authentication required. Set registrationToken in config for production use.")
1552+
}
1553+
1554+
// Parse the request body
1555+
var req clientRegistrationRequest
1556+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1557+
s.logger.ErrorContext(ctx, "failed to parse registration request", "err", err)
1558+
s.registrationErrHelper(w, errInvalidRequest, "Invalid JSON request body", http.StatusBadRequest)
1559+
return
1560+
}
1561+
1562+
// Validate required fields
1563+
if len(req.RedirectURIs) == 0 {
1564+
s.registrationErrHelper(w, errInvalidRequest, "redirect_uris is required", http.StatusBadRequest)
1565+
return
1566+
}
1567+
1568+
// Apply default values
1569+
if req.TokenEndpointAuthMethod == "" {
1570+
req.TokenEndpointAuthMethod = "client_secret_basic"
1571+
}
1572+
if len(req.GrantTypes) == 0 {
1573+
req.GrantTypes = []string{grantTypeAuthorizationCode, grantTypeRefreshToken}
1574+
}
1575+
if len(req.ResponseTypes) == 0 {
1576+
req.ResponseTypes = []string{responseTypeCode}
1577+
}
1578+
1579+
// Validate token_endpoint_auth_method
1580+
if req.TokenEndpointAuthMethod != "client_secret_basic" && req.TokenEndpointAuthMethod != "client_secret_post" && req.TokenEndpointAuthMethod != "none" {
1581+
s.registrationErrHelper(w, errInvalidRequest, "Unsupported token_endpoint_auth_method", http.StatusBadRequest)
1582+
return
1583+
}
1584+
1585+
// Validate grant_types
1586+
for _, gt := range req.GrantTypes {
1587+
if !contains(s.supportedGrantTypes, gt) {
1588+
s.registrationErrHelper(w, errInvalidRequest, fmt.Sprintf("Unsupported grant_type: %s", gt), http.StatusBadRequest)
1589+
return
1590+
}
1591+
}
1592+
1593+
// Validate response_types
1594+
for _, rt := range req.ResponseTypes {
1595+
if !s.supportedResponseTypes[rt] {
1596+
s.registrationErrHelper(w, errInvalidRequest, fmt.Sprintf("Unsupported response_type: %s", rt), http.StatusBadRequest)
1597+
return
1598+
}
1599+
}
1600+
1601+
// Generate client_id and client_secret
1602+
// Following the same pattern as the gRPC API (api.go:CreateClient)
1603+
clientID := storage.NewID()
1604+
1605+
// Determine if this is a public client
1606+
isPublic := req.TokenEndpointAuthMethod == "none"
1607+
1608+
// Only generate secret for confidential clients
1609+
var clientSecret string
1610+
if !isPublic {
1611+
clientSecret = storage.NewID() + storage.NewID() // Double NewID for longer secret
1612+
}
1613+
1614+
// Create the client in storage
1615+
client := storage.Client{
1616+
ID: clientID,
1617+
Secret: clientSecret,
1618+
RedirectURIs: req.RedirectURIs,
1619+
Name: req.ClientName,
1620+
LogoURL: req.LogoURI,
1621+
Public: isPublic,
1622+
}
1623+
1624+
if err := s.storage.CreateClient(ctx, client); err != nil {
1625+
s.logger.ErrorContext(ctx, "failed to create client", "err", err)
1626+
if err == storage.ErrAlreadyExists {
1627+
s.registrationErrHelper(w, errInvalidRequest, "Client ID already exists", http.StatusBadRequest)
1628+
} else {
1629+
s.registrationErrHelper(w, errServerError, "Failed to register client", http.StatusInternalServerError)
1630+
}
1631+
return
1632+
}
1633+
1634+
// Build the response
1635+
resp := clientRegistrationResponse{
1636+
ClientID: clientID,
1637+
ClientSecret: clientSecret,
1638+
ClientSecretExpiresAt: 0, // 0 indicates the secret never expires
1639+
ClientName: req.ClientName,
1640+
RedirectURIs: req.RedirectURIs,
1641+
TokenEndpointAuthMethod: req.TokenEndpointAuthMethod,
1642+
GrantTypes: req.GrantTypes,
1643+
ResponseTypes: req.ResponseTypes,
1644+
Scope: req.Scope,
1645+
LogoURI: req.LogoURI,
1646+
}
1647+
1648+
// For public clients, don't return the secret
1649+
if isPublic {
1650+
resp.ClientSecret = ""
1651+
}
1652+
1653+
// Return HTTP 201 Created
1654+
w.Header().Set("Content-Type", "application/json")
1655+
w.WriteHeader(http.StatusCreated)
1656+
if err := json.NewEncoder(w).Encode(resp); err != nil {
1657+
s.logger.ErrorContext(ctx, "failed to encode registration response", "err", err)
1658+
}
1659+
}
1660+
1661+
func (s *Server) registrationErrHelper(w http.ResponseWriter, typ, description string, statusCode int) {
1662+
if err := tokenErr(w, typ, description, statusCode); err != nil {
1663+
s.logger.Error("registration error response", "err", err)
1664+
}
1665+
}

0 commit comments

Comments
 (0)