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