From f9332efc1d6d21bebf6199d0d48f0657227ee8b1 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Tue, 22 Dec 2020 10:00:43 -0800 Subject: [PATCH] feat: security & autoconfiguration --- autoconfig.go | 20 +++++++++++ openapi.go | 20 ++++++++++- router.go | 91 ++++++++++++++++++++++++++++++++++++++-------- router_test.go | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 autoconfig.go diff --git a/autoconfig.go b/autoconfig.go new file mode 100644 index 00000000..f0c7f750 --- /dev/null +++ b/autoconfig.go @@ -0,0 +1,20 @@ +package huma + +// AutoConfigVar represents a variable given by the user when prompted during +// auto-configuration setup of an API. +type AutoConfigVar struct { + Description string `json:"description,omitempty"` + Example string `json:"example,omitempty"` + Default interface{} `json:"default,omitempty"` + Enum []interface{} `json:"enum,omitempty"` +} + +// AutoConfig holds an API's automatic configuration settings for the CLI. These +// are advertised via OpenAPI extension and picked up by the CLI to make it +// easier to get started using an API. +type AutoConfig struct { + Security string `json:"security"` + Headers map[string]string `json:"headers,omitempty"` + Prompt map[string]AutoConfigVar `json:"prompt,omitempty"` + Params map[string]string `json:"params"` +} diff --git a/openapi.go b/openapi.go index 522a9f6a..ab0f9f74 100644 --- a/openapi.go +++ b/openapi.go @@ -46,7 +46,8 @@ type oaParam struct { } type oaComponents struct { - Schemas map[string]*schema.Schema `json:"schemas,omitempty"` + Schemas map[string]*schema.Schema `json:"schemas,omitempty"` + SecuritySchemes map[string]oaSecurityScheme `json:"securitySchemes,omitempty"` } func (c *oaComponents) AddSchema(t reflect.Type, mode schema.Mode, hint string) string { @@ -100,3 +101,20 @@ func (c *oaComponents) AddSchema(t reflect.Type, mode schema.Mode, hint string) return "#/components/schemas/" + name } + +type oaFlow struct { + AuthorizationURL string `json:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty"` + Scopes map[string]string `json:"scopes,omitempty"` +} + +type oaFlows struct { + ClientCredentials *oaFlow `json:"clientCredentials,omitempty"` + AuthorizationCode *oaFlow `json:"authorizationCode,omitempty"` +} + +type oaSecurityScheme struct { + Type string `json:"type"` + Scheme string `json:"scheme,omitempty"` + Flows oaFlows `json:"flows,omitempty"` +} diff --git a/router.go b/router.go index 2121ac68..a3fa8769 100644 --- a/router.go +++ b/router.go @@ -33,13 +33,15 @@ type Router struct { mux *chi.Mux resources []*Resource - title string - version string - description string - contact oaContact - servers []oaServer - // securitySchemes - // security + title string + version string + description string + contact oaContact + servers []oaServer + securitySchemes map[string]oaSecurityScheme + security map[string][]string + + autoConfig *AutoConfig // Documentation handler function docsPrefix string @@ -72,7 +74,8 @@ func (r *Router) OpenAPI() *gabs.Container { } components := &oaComponents{ - Schemas: map[string]*schema.Schema{}, + Schemas: map[string]*schema.Schema{}, + SecuritySchemes: r.securitySchemes, } paths, _ := doc.Object("paths") @@ -82,6 +85,14 @@ func (r *Router) OpenAPI() *gabs.Container { doc.Set(components, "components") + if len(r.security) > 0 { + doc.Set(r.security, "security") + } + + if r.autoConfig != nil { + doc.Set(r.autoConfig, "x-cli-config") + } + if r.openapiHook != nil { r.openapiHook(doc) } @@ -104,6 +115,56 @@ func (r *Router) ServerLink(description, uri string) { }) } +// GatewayBasicAuth documents that the API gateway handles auth using HTTP Basic. +func (r *Router) GatewayBasicAuth(name string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "http", + Scheme: "basic", + } +} + +// GatewayClientCredentials documents that the API gateway handles auth using +// OAuth2 client credentials (pre-shared secret). +func (r *Router) GatewayClientCredentials(name, tokenURL string, scopes map[string]string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "oauth2", + Flows: oaFlows{ + ClientCredentials: &oaFlow{ + TokenURL: tokenURL, + Scopes: scopes, + }, + }, + } +} + +// GatewayAuthCode documents that the API gateway handles auth using +// OAuth2 authorization code (user login). +func (r *Router) GatewayAuthCode(name, authorizeURL, tokenURL string, scopes map[string]string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "oauth2", + Flows: oaFlows{ + AuthorizationCode: &oaFlow{ + AuthorizationURL: authorizeURL, + TokenURL: tokenURL, + Scopes: scopes, + }, + }, + } +} + +// AutoConfig sets up CLI autoconfiguration via `x-cli-config` for use by CLI +// clients, e.g. using a tool like Restish (https://rest.sh/). +func (r *Router) AutoConfig(autoConfig AutoConfig) { + r.autoConfig = &autoConfig +} + +// SecurityRequirement sets up a security requirement for the entire API by +// name and with the given scopes. Use together with the other auth options +// like GatewayAuthCode. +func (r *Router) SecurityRequirement(name string, scopes ...string) { + r.security[name] = scopes +} + // Resource creates a new resource attached to this router at the given path. // The path can include parameters, e.g. `/things/{thing-id}`. Each resource // path must be unique. @@ -256,12 +317,14 @@ func New(docs, version string) *Router { title, desc := splitDocs(docs) r := &Router{ - mux: chi.NewRouter(), - resources: []*Resource{}, - title: title, - description: desc, - version: version, - servers: []oaServer{}, + mux: chi.NewRouter(), + resources: []*Resource{}, + title: title, + description: desc, + version: version, + servers: []oaServer{}, + securitySchemes: map[string]oaSecurityScheme{}, + security: map[string][]string{}, } r.docsHandler = RapiDocHandler(r) diff --git a/router_test.go b/router_test.go index 1886f087..fb1320b7 100644 --- a/router_test.go +++ b/router_test.go @@ -2,6 +2,7 @@ package huma import ( "bytes" + "encoding/json" "io" "io/ioutil" "net/http" @@ -240,3 +241,99 @@ func TestInvalidPathParam(t *testing.T) { }) }) } + +func TestRouterSecurity(t *testing.T) { + app := newTestRouter() + + // Document that the API gateway handles auth via OAuth2 Authorization Code. + app.GatewayAuthCode("default", "https://example.com/authorize", "https://example.com/token", nil) + app.GatewayClientCredentials("m2m", "https://example.com/token", nil) + app.GatewayBasicAuth("basic") + + // Every call must be authenticated using the default auth mechanism + // registered above. + app.SecurityRequirement("default") + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var parsed map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.Nil(t, err) + + assert.Equal(t, parsed["security"], map[string]interface{}{ + "default": nil, + }) + + assert.Equal(t, parsed["components"].(map[string]interface{})["securitySchemes"], map[string]interface{}{ + "default": map[string]interface{}{ + "type": "oauth2", + "flows": map[string]interface{}{ + "authorizationCode": map[string]interface{}{ + "authorizationUrl": "https://example.com/authorize", + "tokenUrl": "https://example.com/token", + }, + }, + }, + "m2m": map[string]interface{}{ + "type": "oauth2", + "flows": map[string]interface{}{ + "clientCredentials": map[string]interface{}{ + "tokenUrl": "https://example.com/token", + }, + }, + }, + "basic": map[string]interface{}{ + "type": "http", + "scheme": "basic", + "flows": map[string]interface{}{}, + }, + }) +} + +// TODO: test app.AutoConfig +func TestRouterAutoConfig(t *testing.T) { + app := newTestRouter() + + app.GatewayAuthCode("authcode", "https://example.com/authorize", "https://example.com/token", nil) + app.SecurityRequirement("authcode") + + app.AutoConfig(AutoConfig{ + Security: "authcode", + Prompt: map[string]AutoConfigVar{ + "extra": { + Description: "Some extra value", + Example: "abc123", + }, + }, + Params: map[string]string{ + "another": "https://example.com/extras/{extra}", + }, + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var parsed map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.Nil(t, err) + + assert.Equal(t, parsed["x-cli-config"], map[string]interface{}{ + "security": "authcode", + "prompt": map[string]interface{}{ + "extra": map[string]interface{}{ + "description": "Some extra value", + "example": "abc123", + }, + }, + "params": map[string]interface{}{ + "another": "https://example.com/extras/{extra}", + }, + }) +}