feat(oauth): manage dynamic client registrations

This commit is contained in:
saltbo
2026-08-02 22:02:52 -04:00
parent 8513b18a8e
commit 9b95117595
14 changed files with 7768 additions and 9 deletions
+621 -2
View File
@@ -7631,6 +7631,27 @@ type PostApiAuthOauth2Register201JSONResponseBodyResponseTypes string
// PostApiAuthOauth2Register201JSONResponseBodyType defines parameters for PostApiAuthOauth2Register.
type PostApiAuthOauth2Register201JSONResponseBodyType string
// GetDynamicOAuthClientRegistration200JSONResponseBody defines parameters for GetDynamicOAuthClientRegistration.
type GetDynamicOAuthClientRegistration200JSONResponseBody struct {
ClientId string `json:"client_id"`
RegistrationAccessToken string `json:"registration_access_token"`
RegistrationClientUri string `json:"registration_client_uri"`
Scope *string `json:"scope,omitempty"`
AdditionalProperties map[string]interface{} `json:"-"`
}
// UpdateDynamicOAuthClientRegistrationJSONBody defines parameters for UpdateDynamicOAuthClientRegistration.
type UpdateDynamicOAuthClientRegistrationJSONBody map[string]interface{}
// UpdateDynamicOAuthClientRegistration200JSONResponseBody defines parameters for UpdateDynamicOAuthClientRegistration.
type UpdateDynamicOAuthClientRegistration200JSONResponseBody struct {
ClientId string `json:"client_id"`
RegistrationAccessToken string `json:"registration_access_token"`
RegistrationClientUri string `json:"registration_client_uri"`
Scope *string `json:"scope,omitempty"`
AdditionalProperties map[string]interface{} `json:"-"`
}
// PostApiAuthOauth2RevokeJSONBody defines parameters for PostApiAuthOauth2Revoke.
type PostApiAuthOauth2RevokeJSONBody struct {
// ClientId OAuth2 client ID
@@ -9114,6 +9135,9 @@ type PostApiAuthOauth2PublicClientPreloginJSONRequestBody PostApiAuthOauth2Publi
// PostApiAuthOauth2RegisterJSONRequestBody defines body for PostApiAuthOauth2Register for application/json ContentType.
type PostApiAuthOauth2RegisterJSONRequestBody PostApiAuthOauth2RegisterJSONBody
// UpdateDynamicOAuthClientRegistrationJSONRequestBody defines body for UpdateDynamicOAuthClientRegistration for application/json ContentType.
type UpdateDynamicOAuthClientRegistrationJSONRequestBody UpdateDynamicOAuthClientRegistrationJSONBody
// PostApiAuthOauth2RevokeJSONRequestBody defines body for PostApiAuthOauth2Revoke for application/json ContentType.
type PostApiAuthOauth2RevokeJSONRequestBody PostApiAuthOauth2RevokeJSONBody
@@ -9378,6 +9402,220 @@ type GrantUserEntitlementJSONRequestBody GrantUserEntitlementJSONBody
// UpdateUserEntitlementJSONRequestBody defines body for UpdateUserEntitlement for application/json ContentType.
type UpdateUserEntitlementJSONRequestBody UpdateUserEntitlementJSONBody
// Getter for additional properties for GetDynamicOAuthClientRegistration200JSONResponseBody. Returns the specified
// element and whether it was found
func (a GetDynamicOAuthClientRegistration200JSONResponseBody) Get(fieldName string) (value interface{}, found bool) {
if a.AdditionalProperties != nil {
value, found = a.AdditionalProperties[fieldName]
}
return
}
// Setter for additional properties for GetDynamicOAuthClientRegistration200JSONResponseBody
func (a *GetDynamicOAuthClientRegistration200JSONResponseBody) Set(fieldName string, value interface{}) {
if a.AdditionalProperties == nil {
a.AdditionalProperties = make(map[string]interface{})
}
a.AdditionalProperties[fieldName] = value
}
// Override default JSON handling for GetDynamicOAuthClientRegistration200JSONResponseBody to handle AdditionalProperties
func (a *GetDynamicOAuthClientRegistration200JSONResponseBody) UnmarshalJSON(b []byte) error {
object := make(map[string]json.RawMessage)
err := json.Unmarshal(b, &object)
if err != nil {
return err
}
if raw, found := object["client_id"]; found {
err = json.Unmarshal(raw, &a.ClientId)
if err != nil {
return fmt.Errorf("error reading 'client_id': %w", err)
}
delete(object, "client_id")
}
if raw, found := object["registration_access_token"]; found {
err = json.Unmarshal(raw, &a.RegistrationAccessToken)
if err != nil {
return fmt.Errorf("error reading 'registration_access_token': %w", err)
}
delete(object, "registration_access_token")
}
if raw, found := object["registration_client_uri"]; found {
err = json.Unmarshal(raw, &a.RegistrationClientUri)
if err != nil {
return fmt.Errorf("error reading 'registration_client_uri': %w", err)
}
delete(object, "registration_client_uri")
}
if raw, found := object["scope"]; found {
err = json.Unmarshal(raw, &a.Scope)
if err != nil {
return fmt.Errorf("error reading 'scope': %w", err)
}
delete(object, "scope")
}
if len(object) != 0 {
a.AdditionalProperties = make(map[string]interface{})
for fieldName, fieldBuf := range object {
var fieldVal interface{}
err := json.Unmarshal(fieldBuf, &fieldVal)
if err != nil {
return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err)
}
a.AdditionalProperties[fieldName] = fieldVal
}
}
return nil
}
// Override default JSON handling for GetDynamicOAuthClientRegistration200JSONResponseBody to handle AdditionalProperties
func (a GetDynamicOAuthClientRegistration200JSONResponseBody) MarshalJSON() ([]byte, error) {
var err error
object := make(map[string]json.RawMessage)
object["client_id"], err = json.Marshal(a.ClientId)
if err != nil {
return nil, fmt.Errorf("error marshaling 'client_id': %w", err)
}
object["registration_access_token"], err = json.Marshal(a.RegistrationAccessToken)
if err != nil {
return nil, fmt.Errorf("error marshaling 'registration_access_token': %w", err)
}
object["registration_client_uri"], err = json.Marshal(a.RegistrationClientUri)
if err != nil {
return nil, fmt.Errorf("error marshaling 'registration_client_uri': %w", err)
}
if a.Scope != nil {
object["scope"], err = json.Marshal(a.Scope)
if err != nil {
return nil, fmt.Errorf("error marshaling 'scope': %w", err)
}
}
for fieldName, field := range a.AdditionalProperties {
object[fieldName], err = json.Marshal(field)
if err != nil {
return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err)
}
}
return json.Marshal(object)
}
// Getter for additional properties for UpdateDynamicOAuthClientRegistration200JSONResponseBody. Returns the specified
// element and whether it was found
func (a UpdateDynamicOAuthClientRegistration200JSONResponseBody) Get(fieldName string) (value interface{}, found bool) {
if a.AdditionalProperties != nil {
value, found = a.AdditionalProperties[fieldName]
}
return
}
// Setter for additional properties for UpdateDynamicOAuthClientRegistration200JSONResponseBody
func (a *UpdateDynamicOAuthClientRegistration200JSONResponseBody) Set(fieldName string, value interface{}) {
if a.AdditionalProperties == nil {
a.AdditionalProperties = make(map[string]interface{})
}
a.AdditionalProperties[fieldName] = value
}
// Override default JSON handling for UpdateDynamicOAuthClientRegistration200JSONResponseBody to handle AdditionalProperties
func (a *UpdateDynamicOAuthClientRegistration200JSONResponseBody) UnmarshalJSON(b []byte) error {
object := make(map[string]json.RawMessage)
err := json.Unmarshal(b, &object)
if err != nil {
return err
}
if raw, found := object["client_id"]; found {
err = json.Unmarshal(raw, &a.ClientId)
if err != nil {
return fmt.Errorf("error reading 'client_id': %w", err)
}
delete(object, "client_id")
}
if raw, found := object["registration_access_token"]; found {
err = json.Unmarshal(raw, &a.RegistrationAccessToken)
if err != nil {
return fmt.Errorf("error reading 'registration_access_token': %w", err)
}
delete(object, "registration_access_token")
}
if raw, found := object["registration_client_uri"]; found {
err = json.Unmarshal(raw, &a.RegistrationClientUri)
if err != nil {
return fmt.Errorf("error reading 'registration_client_uri': %w", err)
}
delete(object, "registration_client_uri")
}
if raw, found := object["scope"]; found {
err = json.Unmarshal(raw, &a.Scope)
if err != nil {
return fmt.Errorf("error reading 'scope': %w", err)
}
delete(object, "scope")
}
if len(object) != 0 {
a.AdditionalProperties = make(map[string]interface{})
for fieldName, fieldBuf := range object {
var fieldVal interface{}
err := json.Unmarshal(fieldBuf, &fieldVal)
if err != nil {
return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err)
}
a.AdditionalProperties[fieldName] = fieldVal
}
}
return nil
}
// Override default JSON handling for UpdateDynamicOAuthClientRegistration200JSONResponseBody to handle AdditionalProperties
func (a UpdateDynamicOAuthClientRegistration200JSONResponseBody) MarshalJSON() ([]byte, error) {
var err error
object := make(map[string]json.RawMessage)
object["client_id"], err = json.Marshal(a.ClientId)
if err != nil {
return nil, fmt.Errorf("error marshaling 'client_id': %w", err)
}
object["registration_access_token"], err = json.Marshal(a.RegistrationAccessToken)
if err != nil {
return nil, fmt.Errorf("error marshaling 'registration_access_token': %w", err)
}
object["registration_client_uri"], err = json.Marshal(a.RegistrationClientUri)
if err != nil {
return nil, fmt.Errorf("error marshaling 'registration_client_uri': %w", err)
}
if a.Scope != nil {
object["scope"], err = json.Marshal(a.Scope)
if err != nil {
return nil, fmt.Errorf("error marshaling 'scope': %w", err)
}
}
for fieldName, field := range a.AdditionalProperties {
object[fieldName], err = json.Marshal(field)
if err != nil {
return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err)
}
}
return json.Marshal(object)
}
// AsCloudflareSaasImageDomainSettingsCloudflare0 returns the union data inside the CloudflareSaasImageDomainSettings_Cloudflare as a CloudflareSaasImageDomainSettingsCloudflare0
func (t CloudflareSaasImageDomainSettings_Cloudflare) AsCloudflareSaasImageDomainSettingsCloudflare0() (CloudflareSaasImageDomainSettingsCloudflare0, error) {
var body CloudflareSaasImageDomainSettingsCloudflare0
@@ -11293,6 +11531,17 @@ type ClientInterface interface {
PostApiAuthOauth2Register(ctx context.Context, body PostApiAuthOauth2RegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// DeleteDynamicOAuthClientRegistration request
DeleteDynamicOAuthClientRegistration(ctx context.Context, clientId string, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetDynamicOAuthClientRegistration request
GetDynamicOAuthClientRegistration(ctx context.Context, clientId string, reqEditors ...RequestEditorFn) (*http.Response, error)
// UpdateDynamicOAuthClientRegistrationWithBody request with any body
UpdateDynamicOAuthClientRegistrationWithBody(ctx context.Context, clientId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
UpdateDynamicOAuthClientRegistration(ctx context.Context, clientId string, body UpdateDynamicOAuthClientRegistrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostApiAuthOauth2RevokeWithBody request with any body
PostApiAuthOauth2RevokeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -13243,6 +13492,54 @@ func (c *Client) PostApiAuthOauth2Register(ctx context.Context, body PostApiAuth
return c.Client.Do(req)
}
func (c *Client) DeleteDynamicOAuthClientRegistration(ctx context.Context, clientId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewDeleteDynamicOAuthClientRegistrationRequest(c.Server, clientId)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) GetDynamicOAuthClientRegistration(ctx context.Context, clientId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewGetDynamicOAuthClientRegistrationRequest(c.Server, clientId)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) UpdateDynamicOAuthClientRegistrationWithBody(ctx context.Context, clientId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewUpdateDynamicOAuthClientRegistrationRequestWithBody(c.Server, clientId, contentType, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) UpdateDynamicOAuthClientRegistration(ctx context.Context, clientId string, body UpdateDynamicOAuthClientRegistrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewUpdateDynamicOAuthClientRegistrationRequest(c.Server, clientId, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) PostApiAuthOauth2RevokeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostApiAuthOauth2RevokeRequestWithBody(c.Server, contentType, body)
if err != nil {
@@ -19571,6 +19868,121 @@ func NewPostApiAuthOauth2RegisterRequestWithBody(server string, contentType stri
return req, nil
}
// NewDeleteDynamicOAuthClientRegistrationRequest generates requests for DeleteDynamicOAuthClientRegistration
func NewDeleteDynamicOAuthClientRegistrationRequest(server string, clientId string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "clientId", clientId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/auth/oauth2/register/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewGetDynamicOAuthClientRegistrationRequest generates requests for GetDynamicOAuthClientRegistration
func NewGetDynamicOAuthClientRegistrationRequest(server string, clientId string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "clientId", clientId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/auth/oauth2/register/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewUpdateDynamicOAuthClientRegistrationRequest calls the generic UpdateDynamicOAuthClientRegistration builder with application/json body
func NewUpdateDynamicOAuthClientRegistrationRequest(server string, clientId string, body UpdateDynamicOAuthClientRegistrationJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewUpdateDynamicOAuthClientRegistrationRequestWithBody(server, clientId, "application/json", bodyReader)
}
// NewUpdateDynamicOAuthClientRegistrationRequestWithBody generates requests for UpdateDynamicOAuthClientRegistration with any type of body
func NewUpdateDynamicOAuthClientRegistrationRequestWithBody(server string, clientId string, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "clientId", clientId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/auth/oauth2/register/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPut, queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
return req, nil
}
// NewPostApiAuthOauth2RevokeRequest calls the generic PostApiAuthOauth2Revoke builder with application/json body
func NewPostApiAuthOauth2RevokeRequest(server string, body PostApiAuthOauth2RevokeJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
@@ -28571,6 +28983,17 @@ type ClientWithResponsesInterface interface {
PostApiAuthOauth2RegisterWithResponse(ctx context.Context, body PostApiAuthOauth2RegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*PostApiAuthOauth2RegisterResponse, error)
// DeleteDynamicOAuthClientRegistrationWithResponse request
DeleteDynamicOAuthClientRegistrationWithResponse(ctx context.Context, clientId string, reqEditors ...RequestEditorFn) (*DeleteDynamicOAuthClientRegistrationResponse, error)
// GetDynamicOAuthClientRegistrationWithResponse request
GetDynamicOAuthClientRegistrationWithResponse(ctx context.Context, clientId string, reqEditors ...RequestEditorFn) (*GetDynamicOAuthClientRegistrationResponse, error)
// UpdateDynamicOAuthClientRegistrationWithBodyWithResponse request with any body
UpdateDynamicOAuthClientRegistrationWithBodyWithResponse(ctx context.Context, clientId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateDynamicOAuthClientRegistrationResponse, error)
UpdateDynamicOAuthClientRegistrationWithResponse(ctx context.Context, clientId string, body UpdateDynamicOAuthClientRegistrationJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateDynamicOAuthClientRegistrationResponse, error)
// PostApiAuthOauth2RevokeWithBodyWithResponse request with any body
PostApiAuthOauth2RevokeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostApiAuthOauth2RevokeResponse, error)
@@ -32766,7 +33189,9 @@ type PostApiAuthOauth2RegisterResponse struct {
Public *bool `json:"public,omitempty"`
// RedirectUris List of allowed redirect uris
RedirectUris *[]string `json:"redirect_uris,omitempty"`
RedirectUris *[]string `json:"redirect_uris,omitempty"`
RegistrationAccessToken string `json:"registration_access_token"`
RegistrationClientUri string `json:"registration_client_uri"`
// ResponseTypes Response types the client may use at the authorization endpoint
ResponseTypes *[]PostApiAuthOauth2Register201JSONResponseBodyResponseTypes `json:"response_types,omitempty"`
@@ -32839,6 +33264,95 @@ func (r PostApiAuthOauth2RegisterResponse) ContentType() string {
return ""
}
type DeleteDynamicOAuthClientRegistrationResponse struct {
Body []byte
HTTPResponse *http.Response
}
// Status returns HTTPResponse.Status
func (r DeleteDynamicOAuthClientRegistrationResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r DeleteDynamicOAuthClientRegistrationResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r DeleteDynamicOAuthClientRegistrationResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type GetDynamicOAuthClientRegistrationResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *GetDynamicOAuthClientRegistration200JSONResponseBody
}
// Status returns HTTPResponse.Status
func (r GetDynamicOAuthClientRegistrationResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r GetDynamicOAuthClientRegistrationResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r GetDynamicOAuthClientRegistrationResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type UpdateDynamicOAuthClientRegistrationResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *UpdateDynamicOAuthClientRegistration200JSONResponseBody
}
// Status returns HTTPResponse.Status
func (r UpdateDynamicOAuthClientRegistrationResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r UpdateDynamicOAuthClientRegistrationResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r UpdateDynamicOAuthClientRegistrationResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type PostApiAuthOauth2RevokeResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -41306,6 +41820,41 @@ func (c *ClientWithResponses) PostApiAuthOauth2RegisterWithResponse(ctx context.
return ParsePostApiAuthOauth2RegisterResponse(rsp)
}
// DeleteDynamicOAuthClientRegistrationWithResponse request returning *DeleteDynamicOAuthClientRegistrationResponse
func (c *ClientWithResponses) DeleteDynamicOAuthClientRegistrationWithResponse(ctx context.Context, clientId string, reqEditors ...RequestEditorFn) (*DeleteDynamicOAuthClientRegistrationResponse, error) {
rsp, err := c.DeleteDynamicOAuthClientRegistration(ctx, clientId, reqEditors...)
if err != nil {
return nil, err
}
return ParseDeleteDynamicOAuthClientRegistrationResponse(rsp)
}
// GetDynamicOAuthClientRegistrationWithResponse request returning *GetDynamicOAuthClientRegistrationResponse
func (c *ClientWithResponses) GetDynamicOAuthClientRegistrationWithResponse(ctx context.Context, clientId string, reqEditors ...RequestEditorFn) (*GetDynamicOAuthClientRegistrationResponse, error) {
rsp, err := c.GetDynamicOAuthClientRegistration(ctx, clientId, reqEditors...)
if err != nil {
return nil, err
}
return ParseGetDynamicOAuthClientRegistrationResponse(rsp)
}
// UpdateDynamicOAuthClientRegistrationWithBodyWithResponse request with arbitrary body returning *UpdateDynamicOAuthClientRegistrationResponse
func (c *ClientWithResponses) UpdateDynamicOAuthClientRegistrationWithBodyWithResponse(ctx context.Context, clientId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateDynamicOAuthClientRegistrationResponse, error) {
rsp, err := c.UpdateDynamicOAuthClientRegistrationWithBody(ctx, clientId, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseUpdateDynamicOAuthClientRegistrationResponse(rsp)
}
func (c *ClientWithResponses) UpdateDynamicOAuthClientRegistrationWithResponse(ctx context.Context, clientId string, body UpdateDynamicOAuthClientRegistrationJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateDynamicOAuthClientRegistrationResponse, error) {
rsp, err := c.UpdateDynamicOAuthClientRegistration(ctx, clientId, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseUpdateDynamicOAuthClientRegistrationResponse(rsp)
}
// PostApiAuthOauth2RevokeWithBodyWithResponse request with arbitrary body returning *PostApiAuthOauth2RevokeResponse
func (c *ClientWithResponses) PostApiAuthOauth2RevokeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostApiAuthOauth2RevokeResponse, error) {
rsp, err := c.PostApiAuthOauth2RevokeWithBody(ctx, contentType, body, reqEditors...)
@@ -49014,7 +49563,9 @@ func ParsePostApiAuthOauth2RegisterResponse(rsp *http.Response) (*PostApiAuthOau
Public *bool `json:"public,omitempty"`
// RedirectUris List of allowed redirect uris
RedirectUris *[]string `json:"redirect_uris,omitempty"`
RedirectUris *[]string `json:"redirect_uris,omitempty"`
RegistrationAccessToken string `json:"registration_access_token"`
RegistrationClientUri string `json:"registration_client_uri"`
// ResponseTypes Response types the client may use at the authorization endpoint
ResponseTypes *[]PostApiAuthOauth2Register201JSONResponseBodyResponseTypes `json:"response_types,omitempty"`
@@ -49107,6 +49658,74 @@ func ParsePostApiAuthOauth2RegisterResponse(rsp *http.Response) (*PostApiAuthOau
return response, nil
}
// ParseDeleteDynamicOAuthClientRegistrationResponse parses an HTTP response from a DeleteDynamicOAuthClientRegistrationWithResponse call
func ParseDeleteDynamicOAuthClientRegistrationResponse(rsp *http.Response) (*DeleteDynamicOAuthClientRegistrationResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &DeleteDynamicOAuthClientRegistrationResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
return response, nil
}
// ParseGetDynamicOAuthClientRegistrationResponse parses an HTTP response from a GetDynamicOAuthClientRegistrationWithResponse call
func ParseGetDynamicOAuthClientRegistrationResponse(rsp *http.Response) (*GetDynamicOAuthClientRegistrationResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &GetDynamicOAuthClientRegistrationResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest GetDynamicOAuthClientRegistration200JSONResponseBody
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParseUpdateDynamicOAuthClientRegistrationResponse parses an HTTP response from a UpdateDynamicOAuthClientRegistrationWithResponse call
func ParseUpdateDynamicOAuthClientRegistrationResponse(rsp *http.Response) (*UpdateDynamicOAuthClientRegistrationResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &UpdateDynamicOAuthClientRegistrationResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest UpdateDynamicOAuthClientRegistration200JSONResponseBody
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParsePostApiAuthOauth2RevokeResponse parses an HTTP response from a PostApiAuthOauth2RevokeWithResponse call
func ParsePostApiAuthOauth2RevokeResponse(rsp *http.Response) (*PostApiAuthOauth2RevokeResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
+14 -2
View File
@@ -32,6 +32,7 @@ Starting from `https://zpan.example/api`, clients discover:
| Protected resource metadata | `/.well-known/oauth-protected-resource/api` |
| Authorization server metadata | `/.well-known/oauth-authorization-server/api/auth` |
| Dynamic client registration | `/api/auth/oauth2/register` |
| Dynamic client registration management (RFC 7592) | URI returned as `registration_client_uri` |
| Pushed authorization requests | `/api/auth/oauth2/par` |
Authorization-server metadata advertises `scopes_supported`,
@@ -41,12 +42,23 @@ catalog endpoint. RFC 7591 clients can register `authorization_details_types`;
ZPan persists and echoes supported values and rejects unknown types as invalid
client metadata.
New dynamic registrations also receive an opaque `registration_access_token`
and a client-specific `registration_client_uri`. The token is stored only as a
hash and authenticates RFC 7592 `GET`, full-replacement `PUT`, and `DELETE`
operations. Configuration reads and updates never return the OAuth
`client_secret`; the secret is returned only when initially issued. Clients
registered before RFC 7592 support remain valid but do not gain a management
credential retroactively. A controller that needs to change such a registration
creates a new registration generation and leaves existing connections pinned to
their original client identity until they are reconnected.
OpenAPI uses standard `security` declarations. Every protected ZPan operation
declares its OAuth scopes, plus cookie and bearer alternatives. Role constraints
that OpenAPI cannot express use the narrow
`x-zpan-authorization-constraints` extension. Better Auth operations and their
generated OpenAPI definitions remain owned by Better Auth and are not rewritten
by ZPan.
generated OpenAPI definitions remain owned by Better Auth. ZPan augments the
dynamic-registration response and adds the RFC 7592 configuration endpoint that
is implemented at its auth boundary.
## Workspace Authorization Details
@@ -0,0 +1,10 @@
CREATE TABLE `oauthClientRegistration` (
`client_id` text PRIMARY KEY NOT NULL,
`token_hash` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauthClient`(`client_id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauthClientRegistration_token_hash_unique` ON `oauthClientRegistration` (`token_hash`);--> statement-breakpoint
CREATE INDEX `oauthClientRegistration_token_hash_idx` ON `oauthClientRegistration` (`token_hash`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -631,6 +631,13 @@
"when": 1785649535282,
"tag": "0090_oauth-rar-par",
"breakpoints": true
},
{
"idx": 91,
"version": "6",
"when": 1785721689950,
"tag": "0091_oauth-client-registration-management",
"breakpoints": true
}
]
}
@@ -0,0 +1,71 @@
import { and, eq } from 'drizzle-orm'
import { oauthClient, oauthClientRegistration, oauthClientResource, oauthResource } from '../../db/auth-schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
export type ManagedOAuthClient = typeof oauthClient.$inferSelect
export type ManagedOAuthClientUpdate = Partial<typeof oauthClient.$inferInsert>
export async function insertOAuthClientRegistration(db: Database, clientId: string, tokenHash: string): Promise<void> {
await db.insert(oauthClientRegistration).values({ clientId, tokenHash })
}
export async function findManagedOAuthClient(
db: Database,
clientId: string,
tokenHash: string,
): Promise<ManagedOAuthClient | null> {
const [row] = await db
.select({ client: oauthClient })
.from(oauthClientRegistration)
.innerJoin(oauthClient, eq(oauthClient.clientId, oauthClientRegistration.clientId))
.where(and(eq(oauthClientRegistration.clientId, clientId), eq(oauthClientRegistration.tokenHash, tokenHash)))
.limit(1)
return row?.client ?? null
}
export async function getManagedOAuthClient(db: Database, clientId: string): Promise<ManagedOAuthClient | null> {
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1)
return client ?? null
}
export async function deleteManagedOAuthClient(db: Database, clientId: string): Promise<void> {
await db.delete(oauthClient).where(eq(oauthClient.clientId, clientId))
}
export async function isOAuthResourceAvailable(db: Database, resourceId: string): Promise<boolean> {
const [resource] = await db
.select({ disabled: oauthResource.disabled })
.from(oauthResource)
.where(eq(oauthResource.identifier, resourceId))
.limit(1)
return Boolean(resource && !resource.disabled)
}
export async function replaceManagedOAuthClient(
db: Database,
clientId: string,
update: ManagedOAuthClientUpdate,
resourceIds: string[],
): Promise<void> {
const resourceQueries = resourceIds.map((resourceId) =>
db.insert(oauthClientResource).values({
id: `${clientId}::${resourceId}`,
clientId,
resourceId,
}),
)
await executeWriteTransaction(db, [
db.update(oauthClient).set(update).where(eq(oauthClient.clientId, clientId)),
db.delete(oauthClientResource).where(eq(oauthClientResource.clientId, clientId)),
...resourceQueries,
])
}
export async function listManagedOAuthClientResources(db: Database, clientId: string): Promise<string[]> {
const rows = await db
.select({ resourceId: oauthClientResource.resourceId })
.from(oauthClientResource)
.where(eq(oauthClientResource.clientId, clientId))
return rows.map((row) => row.resourceId)
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { OAUTH_RESOURCE_SCOPES, OAUTH_SCOPE_DESCRIPTIONS } from '@shared/oauth'
import type { Context } from 'hono'
import { cors } from 'hono/cors'
import type { Auth } from './auth'
import { addOAuthClientRegistrationManagementOpenApi } from './auth/oauth-client-registration-management'
import { cacheServerTiming, runWithCacheEvents } from './cache/context'
import { createDeps } from './composition'
import { isPotentialWebDavPublicRequest, isWebDavPublicRequest } from './domain/webdav-public-url'
@@ -137,7 +138,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
app.route('/api/auth/oauth2/authorization-details/catalog', oauthAuthorizationDetails)
app.on(['POST', 'GET', 'HEAD'], '/api/auth/*', async (c) => {
app.on(['POST', 'GET', 'HEAD', 'PUT', 'DELETE'], '/api/auth/*', async (c) => {
const a = c.get('auth')
const revokeRequest = c.req.path === '/api/auth/oauth2/revoke' ? c.req.raw.clone() : null
const response = await a.handler(c.req.raw)
@@ -281,6 +282,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
},
},
}
addOAuthClientRegistrationManagementOpenApi(doc)
doc.components.schemas = {
...(authDoc.components?.schemas as typeof doc.components.schemas),
...doc.components.schemas,
+103
View File
@@ -874,9 +874,12 @@ describe('OAuth consent guards', () => {
expect(body).toMatchObject({
client_id: expect.any(String),
client_secret: expect.any(String),
registration_access_token: expect.stringMatching(/^zpr_/),
registration_client_uri: expect.stringMatching(/^http:\/\/localhost:3000\/api\/auth\/oauth2\/register\//),
token_endpoint_auth_method: 'client_secret_basic',
authorization_details_types: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
})
expect(res.headers.get('Cache-Control')).toBe('no-store')
expect(String(body.scope).split(' ')).toEqual(
expect.arrayContaining(['openid', 'offline_access', 'workspaces:discover', 'objects:read']),
)
@@ -898,6 +901,106 @@ describe('OAuth consent guards', () => {
)
})
it('reads, replaces, and deletes a dynamic client through its RFC 7592 configuration endpoint', async () => {
const ctx = await createTestApp()
const registration = await ctx.app.request('/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Managed Broker',
redirect_uris: ['https://broker.example.com/oauth/callback'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
scope: 'openid offline_access',
}),
})
const registered = (await registration.json()) as {
client_id: string
client_secret: string
registration_access_token: string
registration_client_uri: string
}
const authorization = { Authorization: `Bearer ${registered.registration_access_token}` }
const [storedManagementCredential] = await ctx.db.select().from(authSchema.oauthClientRegistration)
expect(storedManagementCredential).toMatchObject({ clientId: registered.client_id })
expect(storedManagementCredential?.tokenHash).not.toBe(registered.registration_access_token)
const unauthenticated = await ctx.app.request(registered.registration_client_uri)
expect(unauthenticated.status).toBe(401)
expect(unauthenticated.headers.get('WWW-Authenticate')).toContain('invalid_token')
const read = await ctx.app.request(registered.registration_client_uri, { headers: authorization })
expect(read.status).toBe(200)
const current = (await read.json()) as Record<string, unknown>
expect(current).toMatchObject({
client_id: registered.client_id,
client_name: 'Managed Broker',
registration_access_token: registered.registration_access_token,
registration_client_uri: registered.registration_client_uri,
})
expect(current).not.toHaveProperty('client_secret')
const update = await ctx.app.request(registered.registration_client_uri, {
method: 'PUT',
headers: { ...authorization, 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: registered.client_id,
client_secret: registered.client_secret,
client_name: 'Managed Broker v2',
redirect_uris: ['https://broker.example.com/oauth/callback-v2'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
scope: 'openid offline_access workspaces:discover',
authorization_details_types: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
}),
})
const updated = (await update.json()) as Record<string, unknown>
expect(update.status, JSON.stringify(updated)).toBe(200)
expect(updated).toMatchObject({
client_id: registered.client_id,
client_name: 'Managed Broker v2',
redirect_uris: ['https://broker.example.com/oauth/callback-v2'],
scope: 'openid offline_access workspaces:discover',
authorization_details_types: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
})
expect(updated).not.toHaveProperty('client_secret')
const forbiddenServerMetadata = await ctx.app.request(registered.registration_client_uri, {
method: 'PUT',
headers: { ...authorization, 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: registered.client_id, registration_access_token: 'replacement' }),
})
expect(forbiddenServerMetadata.status).toBe(400)
await expect(forbiddenServerMetadata.json()).resolves.toMatchObject({ error: 'invalid_client_metadata' })
const wrongSecret = await ctx.app.request(registered.registration_client_uri, {
method: 'PUT',
headers: { ...authorization, 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: registered.client_id,
client_secret: 'not-the-issued-secret',
redirect_uris: ['https://broker.example.com/oauth/callback-v2'],
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
}),
})
expect(wrongSecret.status).toBe(400)
const deleted = await ctx.app.request(registered.registration_client_uri, {
method: 'DELETE',
headers: authorization,
})
expect(deleted.status).toBe(204)
expect(deleted.headers.get('Cache-Control')).toBe('no-store')
const readDeleted = await ctx.app.request(registered.registration_client_uri, { headers: authorization })
expect(readDeleted.status).toBe(401)
expect(await ctx.db.select().from(authSchema.oauthClientRegistration)).toEqual([])
})
it('rejects unsupported authorization detail types during dynamic client registration', async () => {
const ctx = await createTestApp()
const response = await ctx.app.request('/api/auth/oauth2/register', {
+6 -3
View File
@@ -54,6 +54,7 @@ import { createSiteInvitationRepo } from './adapters/repos/site-invitations'
import { initialStorageUsageProjectionQueries } from './adapters/repos/storage-usage-breakdown'
import { createSystemOptionsRepo } from './adapters/repos/system-options'
import { recordUserActivity } from './adapters/repos/user-activity'
import { handleOAuthClientRegistrationManagement } from './auth/oauth-client-registration-management'
import { oauthPushedAuthorizationRequests } from './auth/oauth-par'
import { createOAuthProviderOptions } from './auth/oauth-provider'
import * as authSchema from './db/auth-schema'
@@ -951,15 +952,17 @@ export async function createAuth(
const defaultAuth = await createAuthInstance(false)
let verificationAuth: typeof defaultAuth | null = null
const dynamicHandler = async (request: Request): Promise<Response> => {
if (!usesEmailVerificationPolicy(request)) return defaultAuth.handler(request)
const handle = (auth: typeof defaultAuth) =>
handleOAuthClientRegistrationManagement(request, db, (managedRequest) => auth.handler(managedRequest), baseURL)
if (!usesEmailVerificationPolicy(request)) return handle(defaultAuth)
const required = isEmailVerificationRequired(
await systemOptionsRepo.getValue(EMAIL_VERIFICATION_REQUIRED_OPTION_KEY),
)
if (!required) return defaultAuth.handler(request)
if (!required) return handle(defaultAuth)
verificationAuth ??= await createAuthInstance(true)
return verificationAuth.handler(request)
return handle(verificationAuth)
}
return new Proxy(defaultAuth, {
@@ -0,0 +1,474 @@
import { z } from 'zod'
import {
JWT_BEARER_GRANT_TYPE,
OAUTH_SCOPES,
TOKEN_EXCHANGE_GRANT_TYPE,
WORKSPACE_AUTHORIZATION_DETAIL_TYPE,
} from '../../shared/oauth'
import {
deleteManagedOAuthClient,
findManagedOAuthClient,
getManagedOAuthClient,
insertOAuthClientRegistration,
isOAuthResourceAvailable,
listManagedOAuthClientResources,
type ManagedOAuthClient,
replaceManagedOAuthClient,
} from '../adapters/repos/oauth-client-registration'
import type { Database } from '../platform/interface'
const NO_STORE_HEADERS = {
'Cache-Control': 'no-store',
Pragma: 'no-cache',
}
const CLIENT_CONFIGURATION_PREFIX = '/api/auth/oauth2/register/'
const SUPPORTED_GRANTS = new Set([
'authorization_code',
'refresh_token',
JWT_BEARER_GRANT_TYPE,
TOKEN_EXCHANGE_GRANT_TYPE,
])
const FORBIDDEN_UPDATE_FIELDS = [
'registration_access_token',
'registration_client_uri',
'client_secret_expires_at',
'client_id_issued_at',
] as const
const KNOWN_METADATA_FIELDS = new Set([
'client_id',
'client_secret',
'redirect_uris',
'scope',
'client_name',
'client_uri',
'logo_uri',
'contacts',
'tos_uri',
'policy_uri',
'software_id',
'software_version',
'software_statement',
'post_logout_redirect_uris',
'backchannel_logout_uri',
'backchannel_logout_session_required',
'token_endpoint_auth_method',
'jwks',
'jwks_uri',
'grant_types',
'response_types',
'type',
'subject_type',
'dpop_bound_access_tokens',
'resources',
'require_pkce',
])
const absoluteUrl = z.string().url()
const updateSchema = z
.object({
client_id: z.string().min(1),
client_secret: z.string().min(1).optional(),
redirect_uris: z.array(absoluteUrl).default([]),
scope: z.string().optional(),
client_name: z.string().optional(),
client_uri: absoluteUrl.optional(),
logo_uri: absoluteUrl.optional(),
contacts: z.array(z.string().min(1)).optional(),
tos_uri: absoluteUrl.optional(),
policy_uri: absoluteUrl.optional(),
software_id: z.string().optional(),
software_version: z.string().optional(),
software_statement: z.string().optional(),
post_logout_redirect_uris: z.array(absoluteUrl).optional(),
backchannel_logout_uri: absoluteUrl.optional(),
backchannel_logout_session_required: z.boolean().optional(),
token_endpoint_auth_method: z.string().min(1).default('client_secret_basic'),
jwks: z
.union([
z.array(z.record(z.string(), z.unknown())),
z.object({ keys: z.array(z.record(z.string(), z.unknown())) }),
])
.optional(),
jwks_uri: absoluteUrl.optional(),
grant_types: z.array(z.string().min(1)).default(['authorization_code']),
response_types: z.array(z.literal('code')).optional(),
type: z.enum(['web', 'native', 'user-agent-based']).optional(),
subject_type: z.enum(['public', 'pairwise']).optional(),
dpop_bound_access_tokens: z.boolean().optional(),
authorization_details_types: z.array(z.string().min(1)).optional(),
resources: z.array(absoluteUrl).optional(),
require_pkce: z.boolean().optional(),
})
.passthrough()
export function addOAuthClientRegistrationManagementOpenApi(document: { paths: Record<string, unknown> }): void {
const registration = document.paths['/api/auth/oauth2/register'] as
| { post?: { responses?: Record<string, { content?: Record<string, { schema?: Record<string, unknown> }> }> } }
| undefined
const registrationSchema = registration?.post?.responses?.['201']?.content?.['application/json']?.schema
if (registrationSchema) {
const properties = (registrationSchema.properties ?? {}) as Record<string, unknown>
registrationSchema.properties = {
...properties,
registration_client_uri: { type: 'string', format: 'uri' },
registration_access_token: { type: 'string' },
}
registrationSchema.required = [
...new Set([
...(Array.isArray(registrationSchema.required) ? registrationSchema.required : []),
'registration_client_uri',
'registration_access_token',
]),
]
}
const clientInformationSchema = {
type: 'object',
additionalProperties: true,
properties: {
client_id: { type: 'string' },
registration_client_uri: { type: 'string', format: 'uri' },
registration_access_token: { type: 'string' },
scope: { type: 'string' },
},
required: ['client_id', 'registration_client_uri', 'registration_access_token'],
}
const bearerSecurity = [{ bearerAuth: [] }]
document.paths['/api/auth/oauth2/register/{clientId}'] = {
parameters: [{ name: 'clientId', in: 'path', required: true, schema: { type: 'string' } }],
get: {
operationId: 'getDynamicOAuthClientRegistration',
summary: 'Read a dynamic OAuth client registration',
security: bearerSecurity,
responses: {
'200': {
description: 'Current client registration',
content: { 'application/json': { schema: clientInformationSchema } },
},
},
},
put: {
operationId: 'updateDynamicOAuthClientRegistration',
summary: 'Replace a dynamic OAuth client registration',
security: bearerSecurity,
requestBody: {
required: true,
content: {
'application/json': { schema: { type: 'object', additionalProperties: true, required: ['client_id'] } },
},
},
responses: {
'200': {
description: 'Updated client registration',
content: { 'application/json': { schema: clientInformationSchema } },
},
},
},
delete: {
operationId: 'deleteDynamicOAuthClientRegistration',
summary: 'Delete a dynamic OAuth client registration',
security: bearerSecurity,
responses: { '204': { description: 'Client registration deleted' } },
},
}
}
export async function handleOAuthClientRegistrationManagement(
request: Request,
db: Database,
next: (request: Request) => Promise<Response>,
registrationBaseUrl?: string,
): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === '/api/auth/oauth2/register' && request.method === 'POST') {
return augmentRegistrationResponse(request, db, next, registrationBaseUrl)
}
if (!url.pathname.startsWith(CLIENT_CONFIGURATION_PREFIX)) return next(request)
const encodedClientId = url.pathname.slice(CLIENT_CONFIGURATION_PREFIX.length)
if (!encodedClientId || encodedClientId.includes('/')) return next(request)
let clientId: string
try {
clientId = decodeURIComponent(encodedClientId)
} catch {
return oauthJson(400, { error: 'invalid_request', error_description: 'Client identifier is malformed' })
}
if (!['GET', 'PUT', 'DELETE'].includes(request.method)) {
return oauthJson(
405,
{ error: 'invalid_request', error_description: 'Method not allowed' },
{ Allow: 'GET, PUT, DELETE' },
)
}
const authorization = request.headers.get('Authorization')
const token = bearerToken(authorization)
if (!token) {
return authorization
? bearerError(400, 'invalid_request', 'The registration access token is malformed')
: bearerError(401, 'invalid_token', 'A registration access token is required')
}
const tokenHash = await hashToken(token)
const client = await findManagedOAuthClient(db, clientId, tokenHash)
if (!client) return bearerError(401, 'invalid_token', 'The registration access token is invalid')
const clientConfigurationUrl = configurationUrl(clientId, registrationBaseUrl ?? request.url)
if (request.method === 'GET')
return oauthJson(200, await clientInformation(db, client, clientConfigurationUrl, token))
if (request.method === 'DELETE') {
await deleteManagedOAuthClient(db, clientId)
return new Response(null, { status: 204, headers: NO_STORE_HEADERS })
}
return updateClient(request, db, client, clientConfigurationUrl, token)
}
async function augmentRegistrationResponse(
request: Request,
db: Database,
next: (request: Request) => Promise<Response>,
registrationBaseUrl?: string,
): Promise<Response> {
const response = await next(request)
if (response.status !== 201) return response
const body = (await response.clone().json()) as Record<string, unknown>
const clientId = typeof body.client_id === 'string' ? body.client_id : null
if (!clientId) return response
const token = registrationToken()
try {
await insertOAuthClientRegistration(db, clientId, await hashToken(token))
} catch (error) {
await deleteManagedOAuthClient(db, clientId)
throw error
}
body.registration_client_uri = configurationUrl(clientId, registrationBaseUrl ?? request.url).href
body.registration_access_token = token
return Response.json(body, {
status: 201,
headers: mergedHeaders(response.headers, NO_STORE_HEADERS),
})
}
async function updateClient(
request: Request,
db: Database,
current: ManagedOAuthClient,
url: URL,
registrationToken: string,
): Promise<Response> {
if (!request.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')) {
return oauthJson(415, { error: 'invalid_request', error_description: 'Content-Type must be application/json' })
}
let input: unknown
try {
input = await request.json()
} catch {
return oauthJson(400, { error: 'invalid_request', error_description: 'Request body must be valid JSON' })
}
const raw = input && typeof input === 'object' && !Array.isArray(input) ? (input as Record<string, unknown>) : null
if (!raw) return invalidClientMetadata('Request body must be a JSON object')
const forbidden = FORBIDDEN_UPDATE_FIELDS.find((field) => field in raw)
if (forbidden) return invalidClientMetadata(`${forbidden} must not be included in an update request`)
const parsed = updateSchema.safeParse(raw)
if (!parsed.success) return invalidClientMetadata(parsed.error.issues[0]?.message ?? 'Invalid client metadata')
const metadata = parsed.data
if (metadata.client_id !== current.clientId)
return invalidClientMetadata('client_id must match the registered client')
if (metadata.client_secret && !(await matchesStoredClientSecret(metadata.client_secret, current.clientSecret))) {
return invalidClientMetadata('client_secret must match the currently issued client secret')
}
if (metadata.token_endpoint_auth_method !== current.tokenEndpointAuthMethod) {
return invalidClientMetadata('token_endpoint_auth_method cannot be changed without rotating client credentials')
}
const validationError = validateMetadata(metadata)
if (validationError) return invalidClientMetadata(validationError)
for (const resourceId of metadata.resources ?? []) {
if (!(await isOAuthResourceAvailable(db, resourceId))) {
return oauthJson(400, {
error: 'invalid_target',
error_description: `requested resource ${resourceId} is unavailable`,
})
}
}
const extensionMetadata = Object.fromEntries(
Object.entries(raw).filter(
([key]) => !KNOWN_METADATA_FIELDS.has(key) && !FORBIDDEN_UPDATE_FIELDS.includes(key as never),
),
)
await replaceManagedOAuthClient(
db,
current.clientId,
{
scopes: json(metadata.scope ? uniqueWords(metadata.scope) : undefined),
name: metadata.client_name ?? null,
uri: metadata.client_uri ?? null,
icon: metadata.logo_uri ?? null,
contacts: json(metadata.contacts),
tos: metadata.tos_uri ?? null,
policy: metadata.policy_uri ?? null,
softwareId: metadata.software_id ?? null,
softwareVersion: metadata.software_version ?? null,
softwareStatement: metadata.software_statement ?? null,
redirectUris: JSON.stringify(metadata.redirect_uris),
postLogoutRedirectUris: json(metadata.post_logout_redirect_uris),
backchannelLogoutUri: metadata.backchannel_logout_uri ?? null,
backchannelLogoutSessionRequired: metadata.backchannel_logout_session_required ?? null,
jwks: metadata.jwks ? JSON.stringify(normalizeJwks(metadata.jwks)) : null,
jwksUri: metadata.jwks_uri ?? null,
grantTypes: JSON.stringify(metadata.grant_types),
responseTypes: json(metadata.response_types),
type: metadata.type ?? null,
requirePKCE: metadata.require_pkce ?? null,
dpopBoundAccessTokens: metadata.dpop_bound_access_tokens ?? false,
subjectType: metadata.subject_type ?? null,
metadata: Object.keys(extensionMetadata).length > 0 ? JSON.stringify(extensionMetadata) : null,
updatedAt: new Date(),
},
metadata.resources ?? [],
)
const updated = await getManagedOAuthClient(db, current.clientId)
if (!updated) return bearerError(401, 'invalid_token', 'The registered client no longer exists')
return oauthJson(200, await clientInformation(db, updated, url, registrationToken))
}
function validateMetadata(metadata: z.infer<typeof updateSchema>): string | null {
if (metadata.grant_types.some((grant) => !SUPPORTED_GRANTS.has(grant)))
return 'grant_types contains an unsupported grant type'
if (metadata.grant_types.includes('authorization_code')) {
if (metadata.redirect_uris.length === 0) return 'redirect_uris is required for authorization_code clients'
if (!metadata.response_types?.includes('code'))
return 'response_types must include code for authorization_code clients'
} else if (metadata.response_types?.includes('code')) {
return 'response_types cannot include code without the authorization_code grant'
}
if (uniqueWords(metadata.scope ?? '').some((scope) => !(OAUTH_SCOPES as readonly string[]).includes(scope))) {
return 'scope contains an unsupported scope'
}
if (metadata.authorization_details_types?.some((type) => type !== WORKSPACE_AUTHORIZATION_DETAIL_TYPE)) {
return 'authorization_details_types contains an unsupported type'
}
if (metadata.jwks && metadata.jwks_uri) return 'jwks and jwks_uri are mutually exclusive'
if (metadata.jwks_uri && new URL(metadata.jwks_uri).protocol !== 'https:') return 'jwks_uri must use HTTPS'
return null
}
async function clientInformation(
db: Database,
client: ManagedOAuthClient,
requestUrl: URL,
registrationToken: string,
): Promise<Record<string, unknown>> {
const extensionMetadata = parseObject(client.metadata)
const resources = await listManagedOAuthClientResources(db, client.clientId)
return compact({
...extensionMetadata,
registration_access_token: registrationToken,
registration_client_uri: requestUrl.href,
client_id: client.clientId,
client_id_issued_at: Math.floor(client.createdAt.getTime() / 1000),
scope: parseArray(client.scopes)?.join(' '),
client_name: client.name,
client_uri: client.uri,
logo_uri: client.icon,
contacts: parseArray(client.contacts),
tos_uri: client.tos,
policy_uri: client.policy,
software_id: client.softwareId,
software_version: client.softwareVersion,
software_statement: client.softwareStatement,
redirect_uris: parseArray(client.redirectUris) ?? [],
post_logout_redirect_uris: parseArray(client.postLogoutRedirectUris),
backchannel_logout_uri: client.backchannelLogoutUri,
backchannel_logout_session_required: client.backchannelLogoutSessionRequired,
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
jwks: client.jwks ? JSON.parse(client.jwks) : undefined,
jwks_uri: client.jwksUri,
grant_types: parseArray(client.grantTypes),
response_types: parseArray(client.responseTypes),
type: client.type,
require_pkce: client.requirePKCE,
dpop_bound_access_tokens: client.dpopBoundAccessTokens,
subject_type: client.subjectType,
resources: resources.length > 0 ? resources : undefined,
})
}
function bearerToken(header: string | null): string | null {
if (!header) return null
const match = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/.exec(header)
return match?.[1] ?? null
}
function bearerError(status: number, error: string, description: string): Response {
return oauthJson(status, { error, error_description: description }, { 'WWW-Authenticate': `Bearer error="${error}"` })
}
function invalidClientMetadata(description: string): Response {
return oauthJson(400, { error: 'invalid_client_metadata', error_description: description })
}
function oauthJson(status: number, body: Record<string, unknown>, headers?: HeadersInit): Response {
return Response.json(body, { status, headers: mergedHeaders(headers, NO_STORE_HEADERS) })
}
function mergedHeaders(...inputs: Array<HeadersInit | undefined>): Headers {
const headers = new Headers()
for (const input of inputs) {
if (input)
new Headers(input).forEach((value, key) => {
headers.set(key, value)
})
}
return headers
}
function registrationToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return `zpr_${base64Url(bytes)}`
}
function configurationUrl(clientId: string, baseUrl: string): URL {
const endpoint = new URL(CLIENT_CONFIGURATION_PREFIX, baseUrl).href
return new URL(encodeURIComponent(clientId), endpoint)
}
async function hashToken(token: string): Promise<string> {
return base64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))))
}
async function matchesStoredClientSecret(candidate: string, stored: string | null): Promise<boolean> {
return Boolean(stored) && (await hashToken(candidate)) === stored
}
function base64Url(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
}
function uniqueWords(value: string): string[] {
return [...new Set(value.split(/\s+/).filter(Boolean))]
}
function json(value: unknown[] | undefined): string | null {
return value === undefined ? null : JSON.stringify(value)
}
function parseArray(value: string | null): string[] | undefined {
return value ? (JSON.parse(value) as string[]) : undefined
}
function parseObject(value: string | null): Record<string, unknown> {
return value ? (JSON.parse(value) as Record<string, unknown>) : {}
}
function normalizeJwks(value: Array<Record<string, unknown>> | { keys: Array<Record<string, unknown>> }) {
return Array.isArray(value) ? { keys: value } : value
}
function compact(input: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null))
}
+12
View File
@@ -4,6 +4,7 @@ import {
downloaderBootstrapCredential,
oauthAccessToken,
oauthClient,
oauthClientRegistration,
oauthConsent,
oauthPushedAuthorizationRequest,
oauthRefreshToken,
@@ -110,6 +111,17 @@ describe('OAuth tables', () => {
])
})
it('stores hashed dynamic registration management credentials', () => {
const { foreignKeys, indexes } = getTableConfig(oauthClientRegistration)
expect(oauthClientRegistration.clientId.name).toBe('client_id')
expect(oauthClientRegistration.tokenHash.name).toBe('token_hash')
expect(oauthClientRegistration.tokenHash.notNull).toBe(true)
expect(oauthClientRegistration.tokenHash.isUnique).toBe(true)
expect(foreignKeys[0]?.reference().foreignColumns[0].name).toBe('client_id')
expect(indexes.map((index) => index.config.name)).toEqual(['oauthClientRegistration_token_hash_idx'])
})
it('declares refresh-token relationships and lookup indexes', () => {
const { foreignKeys, indexes } = getTableConfig(oauthRefreshToken)
+18
View File
@@ -280,6 +280,24 @@ export const oauthClient = sqliteTable(
(table) => [index('oauthClient_client_id_idx').on(table.clientId), index('oauthClient_user_id_idx').on(table.userId)],
)
export const oauthClientRegistration = sqliteTable(
'oauthClientRegistration',
{
clientId: text('client_id')
.primaryKey()
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index('oauthClientRegistration_token_hash_idx').on(table.tokenHash)],
)
export const oauthResource = sqliteTable(
'oauthResource',
{
+24 -1
View File
@@ -11,7 +11,7 @@ describe('global OpenAPI document', () => {
expect(res.status).toBe(200)
const doc = (await res.json()) as {
openapi: string
paths: Record<string, { get?: { tags?: string[] } }>
paths: Record<string, { get?: { tags?: string[] }; post?: Record<string, unknown> }>
tags?: { name: string }[]
}
expect(doc.openapi).toBe('3.1.0')
@@ -32,6 +32,7 @@ describe('global OpenAPI document', () => {
'/api/downloads/downloaders/{id}',
'/api/events',
'/api/auth/oauth2/authorization-details/catalog',
'/api/auth/oauth2/register/{clientId}',
'/api/objects',
'/api/objects/{id}',
'/api/objects/{id}/uploads/{uploadSessionId}/parts',
@@ -46,6 +47,28 @@ describe('global OpenAPI document', () => {
operationId: 'listAuthorizationDetailsCatalog',
security: [{ oauth2: [AuthorizationScope.WORKSPACES_DISCOVER] }],
})
expect(doc.paths['/api/auth/oauth2/register/{clientId}']).toMatchObject({
get: { operationId: 'getDynamicOAuthClientRegistration', security: [{ bearerAuth: [] }] },
put: { operationId: 'updateDynamicOAuthClientRegistration', security: [{ bearerAuth: [] }] },
delete: { operationId: 'deleteDynamicOAuthClientRegistration', security: [{ bearerAuth: [] }] },
})
expect(doc.paths['/api/auth/oauth2/register']?.post).toMatchObject({
responses: {
'201': {
content: {
'application/json': {
schema: {
properties: {
registration_client_uri: { type: 'string', format: 'uri' },
registration_access_token: { type: 'string' },
},
required: expect.arrayContaining(['registration_client_uri', 'registration_access_token']),
},
},
},
},
},
})
})
it('serves the Scalar reference UI at /api/docs pointing at the spec', async () => {
+7
View File
@@ -164,6 +164,13 @@ const AUTH_SCHEMA_SQL = `
);
CREATE INDEX IF NOT EXISTS oauthClient_client_id_idx ON oauthClient(client_id);
CREATE INDEX IF NOT EXISTS oauthClient_user_id_idx ON oauthClient(user_id);
CREATE TABLE IF NOT EXISTS oauthClientRegistration (
client_id TEXT PRIMARY KEY REFERENCES oauthClient(client_id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS oauthClientRegistration_token_hash_idx ON oauthClientRegistration(token_hash);
CREATE TABLE IF NOT EXISTS oauthResource (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL UNIQUE,