Repository: danielgtaylor/huma
Pull request #15 (2020-12-22 18:02:16)

feat: security & autoconfiguration
New settings for documenting gateway-based auth schemes and CLI autoconfiguration to make using e.g. [Restish](https://rest.sh) easier.


Patch:
diff --git a/autoconfig.go b/autoconfig.go
new file mode 100644
index 0000000..f0c7f75
--- /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 522a9f6..ab0f9f7 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 2121ac6..a3fa876 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)


Test patch:
diff --git a/router_test.go b/router_test.go
index 1886f08..fb1320b 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}",
+		},
+	})
+}