diff --git a/docs/docs/03-tools/03-openapi.md b/docs/docs/03-tools/03-openapi.md index 70f4a7fd..1ab4f6c1 100644 --- a/docs/docs/03-tools/03-openapi.md +++ b/docs/docs/03-tools/03-openapi.md @@ -40,16 +40,76 @@ Will be resolved as `https://api.example.com/v1`. ## Authentication -GPTScript currently ignores any security schemes and authentication/authorization information in the OpenAPI definition file. This might change in the future. +:::warning +All authentication options will be completely ignored if the server uses HTTP and not HTTPS. +This is to protect users from accidentally sending credentials in plain text. +::: -For now, the only supported type of authentication is bearer tokens. GPTScript will look for a special environment variable based -on the hostname of the server. It looks for the format `GPTSCRIPT__BEARER_TOKEN`, where `` is the hostname, but in all caps and -dots are replaced by underscores. For example, if the server is `https://api.example.com`, GPTScript will look for an environment variable -called `GPTSCRIPT_API_EXAMPLE_COM_BEARER_TOKEN`. If it finds one, it will use it as the bearer token for all requests to that server. +### 1. Security Schemes -:::note -GPTScript will not look for bearer tokens if the server uses HTTP instead of HTTPS. -::: +GPTScript will read the defined [security schemes](https://swagger.io/docs/specification/authentication/) in the OpenAPI definition. The currently supported types are `apiKey` and `http`. +OAuth and OIDC schemes will be ignored. + +GPTScript will look at the `security` defined on the operation (or defined globally, if it is not defined on the operation) before it makes the request. +It will set the necessary headers, cookies, or query parameters based on the corresponding security scheme. + +Environment variables must be set for each security scheme that will be used by the operation. +`` is the hostname of the server, but all caps, and with dashes (`-`) and dots (`.`) replaced with underscores (`_`). +`` is the name of the security scheme, but all caps, and with dashes (`-`) and dots (`.`) replaced with underscores (`_`). + +- For `apiKey`-type and `http`-type with `bearer` scheme, the environment variable is `GPTSCRIPT__` +- For `http`-type with `basic` scheme, the environment variables are `GPTSCRIPT___USERNAME` and `GPTSCRIPT___PASSWORD` + +#### Example + +To explain this better, let's use this example: + +```yaml +servers: + - url: https://api.example.com/v1 +components: + securitySchemes: + MyBasic: + type: http + scheme: basic + + MyAPIKey: + type: apiKey + in: header + name: X-API-Key +security: + - MyBasic: [] + - MyAPIKey: [] +# The rest of the document defines paths, etc. +``` + +In this example, we have two security schemes, and both are defined as the defaults on the global level. +They are separate entries in the global `security` array, so they are treated as a logical OR, and GPTScript will only +need the environment variable for one or the other to make the request. + +When put into the same entry, they would be a logical AND, and the environment variables for both would be required. +It would look like this: + +```yaml +security: + - MyBasic: [] + MyAPIKey: [] +``` + +The environment variable names are as follows: + +- `GPTSCRIPT_API_EXAMPLE_COM_MYBASIC_USERNAME` and `GPTSCRIPT_API_EXAMPLE_COM_MYBASIC_PASSWORD` for basic auth +- `GPTSCRIPT_API_EXAMPLE_COM_MYAPIKEY` for the API key + +### 2. Bearer token for server + +GPTScript can also use a bearer token for all requests to a particular server that don't already have an `Authorization` header. +To do this, set the environment variable `GPTSCRIPT__BEARER_TOKEN`. +If a request to the server already has an `Authorization` header, the bearer token will not be added. + +This can be useful in cases of unsupported auth types. For example, GPTScript does not have built-in support for OAuth, +but you can go through an OAuth flow, get the access token, and set it to the environment variable as a bearer token +for the server and use it that way. ## MIME Types and Request Bodies diff --git a/pkg/engine/openapi.go b/pkg/engine/openapi.go index a12e1309..da9cc392 100644 --- a/pkg/engine/openapi.go +++ b/pkg/engine/openapi.go @@ -15,7 +15,10 @@ import ( "github.com/tidwall/gjson" ) -var SupportedMIMETypes = []string{"application/json", "text/plain", "multipart/form-data"} +var ( + SupportedMIMETypes = []string{"application/json", "text/plain", "multipart/form-data"} + SupportedSecurityTypes = []string{"apiKey", "http"} +) type Parameter struct { Name string `json:"name"` @@ -23,15 +26,25 @@ type Parameter struct { Explode *bool `json:"explode"` } +// A SecurityInfo represents a security scheme in OpenAPI. +type SecurityInfo struct { + Name string `json:"name"` // name as defined in the security schemes + Type string `json:"type"` // http or apiKey + Scheme string `json:"scheme"` // bearer or basic, for type==http + APIKeyName string `json:"apiKeyName"` // name of the API key, for type==apiKey + In string `json:"in"` // header, query, or cookie, for type==apiKey +} + type OpenAPIInstructions struct { - Server string `json:"server"` - Path string `json:"path"` - Method string `json:"method"` - BodyContentMIME string `json:"bodyContentMIME"` - QueryParameters []Parameter `json:"queryParameters"` - PathParameters []Parameter `json:"pathParameters"` - HeaderParameters []Parameter `json:"headerParameters"` - CookieParameters []Parameter `json:"cookieParameters"` + Server string `json:"server"` + Path string `json:"path"` + Method string `json:"method"` + BodyContentMIME string `json:"bodyContentMIME"` + SecurityInfos [][]SecurityInfo `json:"apiKeyInfos"` + QueryParameters []Parameter `json:"queryParameters"` + PathParameters []Parameter `json:"pathParameters"` + HeaderParameters []Parameter `json:"headerParameters"` + CookieParameters []Parameter `json:"cookieParameters"` } // runOpenAPI runs a tool that was generated from an OpenAPI definition. @@ -70,12 +83,17 @@ func (e *Engine) runOpenAPI(tool types.Tool, input string) (*Return, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - // Check for a bearer token (only if using HTTPS) - if u.Scheme == "https" { - // For "https://example.com" the bearer token env name would be GPTSCRIPT_EXAMPLE_COM_BEARER_TOKEN - bearerEnv := "GPTSCRIPT_" + env.ToEnvLike(u.Hostname()) + "_BEARER_TOKEN" - if bearerToken, ok := envMap[bearerEnv]; ok { - req.Header.Set("Authorization", "Bearer "+bearerToken) + // Check for authentication (only if using HTTPS) + if u.Scheme == "https" && len(instructions.SecurityInfos) > 0 { + if err := handleAuths(req, envMap, instructions.SecurityInfos); err != nil { + return nil, fmt.Errorf("error setting up authentication: %w", err) + } + + // If there is a bearer token set for the whole server, and no Authorization header has been defined, use it. + if token, ok := envMap["GPTSCRIPT_"+env.ToEnvLike(u.Hostname())+"_BEARER_TOKEN"]; ok { + if req.Header.Get("Authorization") == "" { + req.Header.Set("Authorization", "Bearer "+token) + } } } @@ -143,6 +161,69 @@ func (e *Engine) runOpenAPI(tool types.Tool, input string) (*Return, error) { }, nil } +// handleAuths will set up the request with the necessary authentication information. +// A set of sets of SecurityInfo is passed in, where each represents a possible set of security options. +func handleAuths(req *http.Request, envMap map[string]string, infoSets [][]SecurityInfo) error { + var missingVariables [][]string + + // We need to find a set of infos where we have all the needed environment variables. + for _, infoSet := range infoSets { + var missing []string // Keep track of any missing environment variables + for _, info := range infoSet { + envNames := []string{"GPTSCRIPT_" + env.ToEnvLike(req.URL.Hostname()) + "_" + env.ToEnvLike(info.Name)} + if info.Type == "http" && info.Scheme == "basic" { + envNames = []string{ + "GPTSCRIPT_" + env.ToEnvLike(req.URL.Hostname()) + "_" + env.ToEnvLike(info.Name) + "_USERNAME", + "GPTSCRIPT_" + env.ToEnvLike(req.URL.Hostname()) + "_" + env.ToEnvLike(info.Name) + "_PASSWORD", + } + } + + for _, envName := range envNames { + if _, ok := envMap[envName]; !ok { + missing = append(missing, envName) + } + } + } + if len(missing) > 0 { + missingVariables = append(missingVariables, missing) + continue + } + + // We're using this info set, because no environment variables were missing. + // Set up the request as needed. + for _, info := range infoSet { + envName := "GPTSCRIPT_" + env.ToEnvLike(req.URL.Hostname()) + "_" + env.ToEnvLike(info.Name) + switch info.Type { + case "apiKey": + switch info.In { + case "header": + req.Header.Set(info.APIKeyName, envMap[envName]) + case "query": + v := url.Values{} + v.Add(info.APIKeyName, envMap[envName]) + req.URL.RawQuery = v.Encode() + case "cookie": + req.AddCookie(&http.Cookie{ + Name: info.APIKeyName, + Value: envMap[envName], + }) + } + case "http": + switch info.Scheme { + case "bearer": + req.Header.Set("Authorization", "Bearer "+envMap[envName]) + case "basic": + req.SetBasicAuth(envMap[envName+"_USERNAME"], envMap[envName+"_PASSWORD"]) + } + } + } + return nil + } + + return fmt.Errorf("did not find the needed environment variables for any of the security options. "+ + "At least one of these sets of environment variables must be provided: %v", missingVariables) +} + // handleQueryParameters extracts each query parameter from the input JSON and adds it to the URL query. func handleQueryParameters(q url.Values, params []Parameter, input string) url.Values { for _, param := range params { diff --git a/pkg/loader/openapi.go b/pkg/loader/openapi.go index 315ec27f..502bdd94 100644 --- a/pkg/loader/openapi.go +++ b/pkg/loader/openapi.go @@ -24,10 +24,25 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) { return nil, err } + var globalSecurity []map[string]struct{} + if t.Security != nil { + for _, item := range t.Security { + current := map[string]struct{}{} + for name := range item { + if scheme, ok := t.Components.SecuritySchemes[name]; ok && slices.Contains(engine.SupportedSecurityTypes, scheme.Value.Type) { + current[name] = struct{}{} + } + } + if len(current) > 0 { + globalSecurity = append(globalSecurity, current) + } + } + } + var ( toolNames []string tools []types.Tool - operationNum = 1 + operationNum = 1 // Each tool gets an operation number, beginning with 1 ) for pathString, pathObj := range t.Paths.Map() { // Handle path-level server override, if one exists @@ -57,6 +72,14 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) { } var ( + // auths are represented as a list of maps, where each map contains the names of the required security schemes. + // Items within the same map are a logical AND. The maps themselves are a logical OR. For example: + // security: # (A AND B) OR (C AND D) + // - A + // B + // - C + // D + auths []map[string]struct{} queryParameters []engine.Parameter pathParameters []engine.Parameter headerParameters []engine.Parameter @@ -142,6 +165,55 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) { } } + // See if there is any auth defined for this operation + var noAuth bool + if operation.Security != nil { + if len(*operation.Security) == 0 { + noAuth = true + } + for _, req := range *operation.Security { + current := map[string]struct{}{} + for name := range req { + current[name] = struct{}{} + } + if len(current) > 0 { + auths = append(auths, current) + } + } + } + + // Use the global security if it was not overridden for this operation + if !noAuth && len(auths) == 0 { + auths = append(auths, globalSecurity...) + } + + // For each set of auths, turn them into SecurityInfos, and drop ones that contain unsupported types. + var infos [][]engine.SecurityInfo + outer: + for _, auth := range auths { + var current []engine.SecurityInfo + for name := range auth { + if scheme, ok := t.Components.SecuritySchemes[name]; ok { + if !slices.Contains(engine.SupportedSecurityTypes, scheme.Value.Type) { + // There is an unsupported type in this auth, so move on to the next one. + continue outer + } + + current = append(current, engine.SecurityInfo{ + Type: scheme.Value.Type, + Name: name, + In: scheme.Value.In, + Scheme: scheme.Value.Scheme, + APIKeyName: scheme.Value.Name, + }) + } + } + + if len(current) > 0 { + infos = append(infos, current) + } + } + // OpenAI will get upset if we have an object schema with no properties, // so we just nil this out if there were no properties added. if len(tool.Arguments.Properties) == 0 { @@ -149,7 +221,7 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) { } var err error - tool.Instructions, err = instructionString(operationServer, method, pathString, bodyMIME, queryParameters, pathParameters, headerParameters, cookieParameters) + tool.Instructions, err = instructionString(operationServer, method, pathString, bodyMIME, queryParameters, pathParameters, headerParameters, cookieParameters, infos) if err != nil { return nil, err } @@ -175,12 +247,13 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) { return tools, nil } -func instructionString(server, method, path, bodyMIME string, queryParameters, pathParameters, headerParameters, cookieParameters []engine.Parameter) (string, error) { +func instructionString(server, method, path, bodyMIME string, queryParameters, pathParameters, headerParameters, cookieParameters []engine.Parameter, infos [][]engine.SecurityInfo) (string, error) { inst := engine.OpenAPIInstructions{ Server: server, Path: path, Method: method, BodyContentMIME: bodyMIME, + SecurityInfos: infos, QueryParameters: queryParameters, PathParameters: pathParameters, HeaderParameters: headerParameters,