Skip to content

Commit dcb0390

Browse files
authored
enhance: openapi: add support for authentication (#207)
Signed-off-by: Grant Linville <[email protected]>
1 parent d566dea commit dcb0390

File tree

3 files changed

+240
-26
lines changed

3 files changed

+240
-26
lines changed

docs/docs/03-tools/03-openapi.md

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,76 @@ Will be resolved as `https://api.example.com/v1`.
4040

4141
## Authentication
4242

43-
GPTScript currently ignores any security schemes and authentication/authorization information in the OpenAPI definition file. This might change in the future.
43+
:::warning
44+
All authentication options will be completely ignored if the server uses HTTP and not HTTPS.
45+
This is to protect users from accidentally sending credentials in plain text.
46+
:::
4447

45-
For now, the only supported type of authentication is bearer tokens. GPTScript will look for a special environment variable based
46-
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
47-
dots are replaced by underscores. For example, if the server is `https://api.example.com`, GPTScript will look for an environment variable
48-
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.
48+
### 1. Security Schemes
4949

50-
:::note
51-
GPTScript will not look for bearer tokens if the server uses HTTP instead of HTTPS.
52-
:::
50+
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`.
51+
OAuth and OIDC schemes will be ignored.
52+
53+
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.
54+
It will set the necessary headers, cookies, or query parameters based on the corresponding security scheme.
55+
56+
Environment variables must be set for each security scheme that will be used by the operation.
57+
`<HOSTNAME>` is the hostname of the server, but all caps, and with dashes (`-`) and dots (`.`) replaced with underscores (`_`).
58+
`<SCHEME NAME>` is the name of the security scheme, but all caps, and with dashes (`-`) and dots (`.`) replaced with underscores (`_`).
59+
60+
- For `apiKey`-type and `http`-type with `bearer` scheme, the environment variable is `GPTSCRIPT_<HOSTNAME>_<SCHEME NAME>`
61+
- For `http`-type with `basic` scheme, the environment variables are `GPTSCRIPT_<HOSTNAME>_<SCHEME NAME>_USERNAME` and `GPTSCRIPT_<HOSTNAME>_<SCHEME NAME>_PASSWORD`
62+
63+
#### Example
64+
65+
To explain this better, let's use this example:
66+
67+
```yaml
68+
servers:
69+
- url: https://api.example.com/v1
70+
components:
71+
securitySchemes:
72+
MyBasic:
73+
type: http
74+
scheme: basic
75+
76+
MyAPIKey:
77+
type: apiKey
78+
in: header
79+
name: X-API-Key
80+
security:
81+
- MyBasic: []
82+
- MyAPIKey: []
83+
# The rest of the document defines paths, etc.
84+
```
85+
86+
In this example, we have two security schemes, and both are defined as the defaults on the global level.
87+
They are separate entries in the global `security` array, so they are treated as a logical OR, and GPTScript will only
88+
need the environment variable for one or the other to make the request.
89+
90+
When put into the same entry, they would be a logical AND, and the environment variables for both would be required.
91+
It would look like this:
92+
93+
```yaml
94+
security:
95+
- MyBasic: []
96+
MyAPIKey: []
97+
```
98+
99+
The environment variable names are as follows:
100+
101+
- `GPTSCRIPT_API_EXAMPLE_COM_MYBASIC_USERNAME` and `GPTSCRIPT_API_EXAMPLE_COM_MYBASIC_PASSWORD` for basic auth
102+
- `GPTSCRIPT_API_EXAMPLE_COM_MYAPIKEY` for the API key
103+
104+
### 2. Bearer token for server
105+
106+
GPTScript can also use a bearer token for all requests to a particular server that don't already have an `Authorization` header.
107+
To do this, set the environment variable `GPTSCRIPT_<HOSTNAME>_BEARER_TOKEN`.
108+
If a request to the server already has an `Authorization` header, the bearer token will not be added.
109+
110+
This can be useful in cases of unsupported auth types. For example, GPTScript does not have built-in support for OAuth,
111+
but you can go through an OAuth flow, get the access token, and set it to the environment variable as a bearer token
112+
for the server and use it that way.
53113

54114
## MIME Types and Request Bodies
55115

pkg/engine/openapi.go

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,36 @@ import (
1515
"github.com/tidwall/gjson"
1616
)
1717

18-
var SupportedMIMETypes = []string{"application/json", "text/plain", "multipart/form-data"}
18+
var (
19+
SupportedMIMETypes = []string{"application/json", "text/plain", "multipart/form-data"}
20+
SupportedSecurityTypes = []string{"apiKey", "http"}
21+
)
1922

2023
type Parameter struct {
2124
Name string `json:"name"`
2225
Style string `json:"style"`
2326
Explode *bool `json:"explode"`
2427
}
2528

29+
// A SecurityInfo represents a security scheme in OpenAPI.
30+
type SecurityInfo struct {
31+
Name string `json:"name"` // name as defined in the security schemes
32+
Type string `json:"type"` // http or apiKey
33+
Scheme string `json:"scheme"` // bearer or basic, for type==http
34+
APIKeyName string `json:"apiKeyName"` // name of the API key, for type==apiKey
35+
In string `json:"in"` // header, query, or cookie, for type==apiKey
36+
}
37+
2638
type OpenAPIInstructions struct {
27-
Server string `json:"server"`
28-
Path string `json:"path"`
29-
Method string `json:"method"`
30-
BodyContentMIME string `json:"bodyContentMIME"`
31-
QueryParameters []Parameter `json:"queryParameters"`
32-
PathParameters []Parameter `json:"pathParameters"`
33-
HeaderParameters []Parameter `json:"headerParameters"`
34-
CookieParameters []Parameter `json:"cookieParameters"`
39+
Server string `json:"server"`
40+
Path string `json:"path"`
41+
Method string `json:"method"`
42+
BodyContentMIME string `json:"bodyContentMIME"`
43+
SecurityInfos [][]SecurityInfo `json:"apiKeyInfos"`
44+
QueryParameters []Parameter `json:"queryParameters"`
45+
PathParameters []Parameter `json:"pathParameters"`
46+
HeaderParameters []Parameter `json:"headerParameters"`
47+
CookieParameters []Parameter `json:"cookieParameters"`
3548
}
3649

3750
// 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) {
7083
return nil, fmt.Errorf("failed to create request: %w", err)
7184
}
7285

73-
// Check for a bearer token (only if using HTTPS)
74-
if u.Scheme == "https" {
75-
// For "https://example.com" the bearer token env name would be GPTSCRIPT_EXAMPLE_COM_BEARER_TOKEN
76-
bearerEnv := "GPTSCRIPT_" + env.ToEnvLike(u.Hostname()) + "_BEARER_TOKEN"
77-
if bearerToken, ok := envMap[bearerEnv]; ok {
78-
req.Header.Set("Authorization", "Bearer "+bearerToken)
86+
// Check for authentication (only if using HTTPS)
87+
if u.Scheme == "https" && len(instructions.SecurityInfos) > 0 {
88+
if err := handleAuths(req, envMap, instructions.SecurityInfos); err != nil {
89+
return nil, fmt.Errorf("error setting up authentication: %w", err)
90+
}
91+
92+
// If there is a bearer token set for the whole server, and no Authorization header has been defined, use it.
93+
if token, ok := envMap["GPTSCRIPT_"+env.ToEnvLike(u.Hostname())+"_BEARER_TOKEN"]; ok {
94+
if req.Header.Get("Authorization") == "" {
95+
req.Header.Set("Authorization", "Bearer "+token)
96+
}
7997
}
8098
}
8199

@@ -143,6 +161,69 @@ func (e *Engine) runOpenAPI(tool types.Tool, input string) (*Return, error) {
143161
}, nil
144162
}
145163

164+
// handleAuths will set up the request with the necessary authentication information.
165+
// A set of sets of SecurityInfo is passed in, where each represents a possible set of security options.
166+
func handleAuths(req *http.Request, envMap map[string]string, infoSets [][]SecurityInfo) error {
167+
var missingVariables [][]string
168+
169+
// We need to find a set of infos where we have all the needed environment variables.
170+
for _, infoSet := range infoSets {
171+
var missing []string // Keep track of any missing environment variables
172+
for _, info := range infoSet {
173+
envNames := []string{"GPTSCRIPT_" + env.ToEnvLike(req.URL.Hostname()) + "_" + env.ToEnvLike(info.Name)}
174+
if info.Type == "http" && info.Scheme == "basic" {
175+
envNames = []string{
176+
"GPTSCRIPT_" + env.ToEnvLike(req.URL.Hostname()) + "_" + env.ToEnvLike(info.Name) + "_USERNAME",
177+
"GPTSCRIPT_" + env.ToEnvLike(req.URL.Hostname()) + "_" + env.ToEnvLike(info.Name) + "_PASSWORD",
178+
}
179+
}
180+
181+
for _, envName := range envNames {
182+
if _, ok := envMap[envName]; !ok {
183+
missing = append(missing, envName)
184+
}
185+
}
186+
}
187+
if len(missing) > 0 {
188+
missingVariables = append(missingVariables, missing)
189+
continue
190+
}
191+
192+
// We're using this info set, because no environment variables were missing.
193+
// Set up the request as needed.
194+
for _, info := range infoSet {
195+
envName := "GPTSCRIPT_" + env.ToEnvLike(req.URL.Hostname()) + "_" + env.ToEnvLike(info.Name)
196+
switch info.Type {
197+
case "apiKey":
198+
switch info.In {
199+
case "header":
200+
req.Header.Set(info.APIKeyName, envMap[envName])
201+
case "query":
202+
v := url.Values{}
203+
v.Add(info.APIKeyName, envMap[envName])
204+
req.URL.RawQuery = v.Encode()
205+
case "cookie":
206+
req.AddCookie(&http.Cookie{
207+
Name: info.APIKeyName,
208+
Value: envMap[envName],
209+
})
210+
}
211+
case "http":
212+
switch info.Scheme {
213+
case "bearer":
214+
req.Header.Set("Authorization", "Bearer "+envMap[envName])
215+
case "basic":
216+
req.SetBasicAuth(envMap[envName+"_USERNAME"], envMap[envName+"_PASSWORD"])
217+
}
218+
}
219+
}
220+
return nil
221+
}
222+
223+
return fmt.Errorf("did not find the needed environment variables for any of the security options. "+
224+
"At least one of these sets of environment variables must be provided: %v", missingVariables)
225+
}
226+
146227
// handleQueryParameters extracts each query parameter from the input JSON and adds it to the URL query.
147228
func handleQueryParameters(q url.Values, params []Parameter, input string) url.Values {
148229
for _, param := range params {

pkg/loader/openapi.go

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,25 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) {
2424
return nil, err
2525
}
2626

27+
var globalSecurity []map[string]struct{}
28+
if t.Security != nil {
29+
for _, item := range t.Security {
30+
current := map[string]struct{}{}
31+
for name := range item {
32+
if scheme, ok := t.Components.SecuritySchemes[name]; ok && slices.Contains(engine.SupportedSecurityTypes, scheme.Value.Type) {
33+
current[name] = struct{}{}
34+
}
35+
}
36+
if len(current) > 0 {
37+
globalSecurity = append(globalSecurity, current)
38+
}
39+
}
40+
}
41+
2742
var (
2843
toolNames []string
2944
tools []types.Tool
30-
operationNum = 1
45+
operationNum = 1 // Each tool gets an operation number, beginning with 1
3146
)
3247
for pathString, pathObj := range t.Paths.Map() {
3348
// Handle path-level server override, if one exists
@@ -57,6 +72,14 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) {
5772
}
5873

5974
var (
75+
// auths are represented as a list of maps, where each map contains the names of the required security schemes.
76+
// Items within the same map are a logical AND. The maps themselves are a logical OR. For example:
77+
// security: # (A AND B) OR (C AND D)
78+
// - A
79+
// B
80+
// - C
81+
// D
82+
auths []map[string]struct{}
6083
queryParameters []engine.Parameter
6184
pathParameters []engine.Parameter
6285
headerParameters []engine.Parameter
@@ -142,14 +165,63 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) {
142165
}
143166
}
144167

168+
// See if there is any auth defined for this operation
169+
var noAuth bool
170+
if operation.Security != nil {
171+
if len(*operation.Security) == 0 {
172+
noAuth = true
173+
}
174+
for _, req := range *operation.Security {
175+
current := map[string]struct{}{}
176+
for name := range req {
177+
current[name] = struct{}{}
178+
}
179+
if len(current) > 0 {
180+
auths = append(auths, current)
181+
}
182+
}
183+
}
184+
185+
// Use the global security if it was not overridden for this operation
186+
if !noAuth && len(auths) == 0 {
187+
auths = append(auths, globalSecurity...)
188+
}
189+
190+
// For each set of auths, turn them into SecurityInfos, and drop ones that contain unsupported types.
191+
var infos [][]engine.SecurityInfo
192+
outer:
193+
for _, auth := range auths {
194+
var current []engine.SecurityInfo
195+
for name := range auth {
196+
if scheme, ok := t.Components.SecuritySchemes[name]; ok {
197+
if !slices.Contains(engine.SupportedSecurityTypes, scheme.Value.Type) {
198+
// There is an unsupported type in this auth, so move on to the next one.
199+
continue outer
200+
}
201+
202+
current = append(current, engine.SecurityInfo{
203+
Type: scheme.Value.Type,
204+
Name: name,
205+
In: scheme.Value.In,
206+
Scheme: scheme.Value.Scheme,
207+
APIKeyName: scheme.Value.Name,
208+
})
209+
}
210+
}
211+
212+
if len(current) > 0 {
213+
infos = append(infos, current)
214+
}
215+
}
216+
145217
// OpenAI will get upset if we have an object schema with no properties,
146218
// so we just nil this out if there were no properties added.
147219
if len(tool.Arguments.Properties) == 0 {
148220
tool.Arguments = nil
149221
}
150222

151223
var err error
152-
tool.Instructions, err = instructionString(operationServer, method, pathString, bodyMIME, queryParameters, pathParameters, headerParameters, cookieParameters)
224+
tool.Instructions, err = instructionString(operationServer, method, pathString, bodyMIME, queryParameters, pathParameters, headerParameters, cookieParameters, infos)
153225
if err != nil {
154226
return nil, err
155227
}
@@ -175,12 +247,13 @@ func getOpenAPITools(t *openapi3.T) ([]types.Tool, error) {
175247
return tools, nil
176248
}
177249

178-
func instructionString(server, method, path, bodyMIME string, queryParameters, pathParameters, headerParameters, cookieParameters []engine.Parameter) (string, error) {
250+
func instructionString(server, method, path, bodyMIME string, queryParameters, pathParameters, headerParameters, cookieParameters []engine.Parameter, infos [][]engine.SecurityInfo) (string, error) {
179251
inst := engine.OpenAPIInstructions{
180252
Server: server,
181253
Path: path,
182254
Method: method,
183255
BodyContentMIME: bodyMIME,
256+
SecurityInfos: infos,
184257
QueryParameters: queryParameters,
185258
PathParameters: pathParameters,
186259
HeaderParameters: headerParameters,

0 commit comments

Comments
 (0)