Skip to content

enhance: openapi: add support for authentication #207

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 68 additions & 8 deletions docs/docs/03-tools/03-openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<HOST>_BEARER_TOKEN`, where `<HOST>` 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.
`<HOSTNAME>` is the hostname of the server, but all caps, and with dashes (`-`) and dots (`.`) replaced with underscores (`_`).
`<SCHEME NAME>` 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_<HOSTNAME>_<SCHEME NAME>`
- For `http`-type with `basic` scheme, the environment variables are `GPTSCRIPT_<HOSTNAME>_<SCHEME NAME>_USERNAME` and `GPTSCRIPT_<HOSTNAME>_<SCHEME NAME>_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_<HOSTNAME>_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

Expand Down
111 changes: 96 additions & 15 deletions pkg/engine/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,36 @@ 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"`
Style string `json:"style"`
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.
Expand Down Expand Up @@ -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 {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@g-linville hi, I just tried the openapi integration with this new code, and using a global _BEARER_TOKEN env var doesn't work anymore, because this code is not reached if I have a security info set, but without the specific _${name} env var.

for example in my case, my openapi document contains:

components:
  securitySchemes:
    bearerAuth:
      bearerFormat: JWT
      scheme: bearer
      type: http

so if I don't have the _BEARERAUTH env var, I get an error - instead of the code trying to lookup the _BEARER_TOKEN env var.

that's not a big issue because I can change the env var on my side, but it's just that it might confuse people...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vbehar Thanks for bringing this to my attention. Yes, this was technically a breaking change, but there has been no release of GPTScript that includes the OpenAPI feature (the only way to use it has been to compile it yourself) so that's why I didn't specifically call it out anywhere.

if req.Header.Get("Authorization") == "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
79 changes: 76 additions & 3 deletions pkg/loader/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -142,14 +165,63 @@ 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 {
tool.Arguments = nil
}

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
}
Expand All @@ -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,
Expand Down