mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(oauth): manage dynamic client registrations
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user