From 8b662edb3e18cac735282e69d5a96b2f3864b274 Mon Sep 17 00:00:00 2001 From: Jason Luong Date: Mon, 16 Jun 2025 13:02:55 +0100 Subject: [PATCH 1/2] fix(auth): auth failure when provided multiple pat regions with a valid region --- pkg/auth/tokenauthenticator.go | 98 ++++++++++++++++++++++++ pkg/auth/tokenauthenticator_test.go | 111 ++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/pkg/auth/tokenauthenticator.go b/pkg/auth/tokenauthenticator.go index 66bab11dd..283a211ad 100644 --- a/pkg/auth/tokenauthenticator.go +++ b/pkg/auth/tokenauthenticator.go @@ -66,3 +66,101 @@ func IsAuthTypePAT(token string) bool { } return false } +<<<<<<< HEAD +======= + +type DeriveEndpointFn func(pat string, config configuration.Configuration, client *http.Client, regions []string) (string, error) + +var _ DeriveEndpointFn = DeriveEndpointFromPAT + +// DeriveEndpointFromPAT iterates a list of Snyk region URLs and tries to make an authenticated request to the Snyk PAT API +// on success, it will return the correct Snyk endpoint from the given PAT +func DeriveEndpointFromPAT(pat string, config configuration.Configuration, client *http.Client, regionUrls []string) (string, error) { + var ( + err error + errs error + endpoint string + ) + + // include the default region as a fallback + if !slices.Contains(regionUrls, constants.SNYK_DEFAULT_API_URL) { + regionUrls = append(regionUrls, constants.SNYK_DEFAULT_API_URL) + } + + for _, url := range regionUrls { + endpoint, err = deriveEndpoint(pat, config, client, url) + if err != nil { + errs = errors.Join(errs, err) + continue + } + break + } + + if errs != nil && len(endpoint) == 0 { + return "", errs + } + + return endpoint, nil +} + +// deriveEndpoint makes an authenticated request to the Snyk PAT API and returns the correct Snyk endpoint from the given PAT +func deriveEndpoint(token string, config configuration.Configuration, client *http.Client, snykRegionUrl string) (string, error) { + apiBaseUrl := snykRegionUrl + if len(apiBaseUrl) == 0 { + apiBaseUrl = constants.SNYK_DEFAULT_API_URL + } + + if !strings.HasPrefix(apiBaseUrl, "hidden") { + apiBaseUrl = fmt.Sprintf("%s/hidden", apiBaseUrl) + } + + patApiClient, err := patAPI.NewClientWithResponses(apiBaseUrl, patAPI.WithHTTPClient(client)) + if err != nil { + return "", fmt.Errorf("failed to create PAT API client: %w", err) + } + + params := &patAPI.GetPatMetadataParams{ + Version: patAPIVersion, + } + + reqEditors := []patAPI.RequestEditorFn{ + func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + return nil + }, + } + + resp, err := patApiClient.GetPatMetadataWithResponse(context.Background(), params, reqEditors...) + if err != nil { + return "", snykError.NewUnauthorisedError("failed to validate PAT; either missing or invalid PAT") + } + + if resp.StatusCode() != http.StatusOK { + return "", snykError.NewUnauthorisedError(fmt.Sprintf("failed to get PAT metadata (status: %d): %s", resp.StatusCode(), string(resp.Body))) + } + + patMetadataBody := resp.ApplicationvndApiJSON200 + if patMetadataBody == nil || patMetadataBody.Data.Attributes.Hostname == nil { + return "", fmt.Errorf("failed to decode PAT metadata response or missing hostname") + } + + hostname := *patMetadataBody.Data.Attributes.Hostname + if hostname == "" { + return "", fmt.Errorf("invalid empty hostname") + } + + authHost, err := redirectAuthHost(hostname) + if err != nil { + return "", err + } + + validHostRegex := config.GetString(CONFIG_KEY_ALLOWED_HOST_REGEXP) + if isValid, err := utils.MatchesRegex(authHost, validHostRegex); err != nil || !isValid { + return "", fmt.Errorf("invalid hostname: %s", authHost) + } + + endpoint := fmt.Sprintf("https://%s", authHost) + + return endpoint, nil +} +>>>>>>> 4607496 (fix(auth): auth failure when provided multiple pat regions with a valid region) diff --git a/pkg/auth/tokenauthenticator_test.go b/pkg/auth/tokenauthenticator_test.go index b26998198..866dec3bf 100644 --- a/pkg/auth/tokenauthenticator_test.go +++ b/pkg/auth/tokenauthenticator_test.go @@ -17,3 +17,114 @@ func TestIsAuthTypePAT(t *testing.T) { // legacy token format assert.False(t, IsAuthTypePAT("f47ac10b-58cc-4372-a567-0e02b2c3d479")) } + +func TestDeriveEndpointFromPAT(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/hidden/self/personal_access_token/metadata" { + w.Header().Set("Content-Type", "application/vnd.api+json") + hostname := "" + w.WriteHeader(http.StatusOK) + authorization := r.Header.Get("Authorization") + + if authorization == "token valid_pat" { + hostname = "api.snyk.io" + } else if authorization == "token valid_eu_pat" { + hostname = "api.eu.snyk.io" + } else if authorization == "token invalid_pat" { + hostname = "invalid.hostname.io" + } else if authorization == "token empty_pat" { + hostname = "" + } + + res := createPatMetadataReponse(t, hostname) + err := json.NewEncoder(w).Encode(res.ApplicationvndApiJSON200) + assert.NoError(t, err) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + config := configuration.NewWithOpts() + config.Set(CONFIG_KEY_ALLOWED_HOST_REGEXP, `^api(\.(.+))?\.snyk\.io$`) + client := server.Client() + + t.Run("Valid PAT", func(t *testing.T) { + endpoint, err := DeriveEndpointFromPAT("valid_pat", config, client, []string{server.URL}) + assert.NoError(t, err) + assert.Equal(t, "https://api.snyk.io", endpoint) + }) + + t.Run("Multiple regions", func(t *testing.T) { + endpoint, err := DeriveEndpointFromPAT("valid_pat", config, client, []string{"https://someRandomUrl.com", server.URL, "https://someOtherRandomUrl.com"}) + assert.NoError(t, err) + assert.Equal(t, "https://api.snyk.io", endpoint) + }) + + t.Run("Valid EU PAT", func(t *testing.T) { + endpoint, err := DeriveEndpointFromPAT("valid_eu_pat", config, client, []string{server.URL}) + assert.NoError(t, err) + assert.Equal(t, "https://api.eu.snyk.io", endpoint) + }) + + t.Run("Unauthorized PAT", func(t *testing.T) { + _, err := DeriveEndpointFromPAT("invalid_pat", config, client, []string{server.URL}) + expectedError := "invalid hostname: api.invalid.hostname.io" + assert.ErrorContains(t, err, expectedError) + }) + + t.Run("Empty Hostname in Response", func(t *testing.T) { + _, err := DeriveEndpointFromPAT("empty_pat", config, client, []string{server.URL}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid empty hostname") + }) + + t.Run("Bad URL", func(t *testing.T) { + _, err := DeriveEndpointFromPAT("empty_pat", config, client, []string{"https://someRandomUrl.com"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Authentication error") + }) +} + +func createPatMetadataReponse(t *testing.T, hostname string) *pat.GetPatMetadataResponse { + t.Helper() + + id := "someRandomId" + return &pat.GetPatMetadataResponse{ + Body: []byte{}, + HTTPResponse: nil, + ApplicationvndApiJSON200: &struct { + Data struct { + Attributes struct { + Hostname *string `json:"hostname,omitempty"` + } `json:"attributes"` + Id pat.PatId `json:"id"` + Type pat.PatType `json:"type"` + } `json:"data"` + Jsonapi *pat.JsonApi `json:"jsonapi,omitempty"` + Links *pat.SelfLink `json:"links,omitempty"` + }{ + Data: struct { + Attributes struct { + Hostname *string `json:"hostname,omitempty"` + } `json:"attributes"` + Id pat.PatId `json:"id"` + Type pat.PatType `json:"type"` + }{ + Attributes: struct { + Hostname *string `json:"hostname,omitempty"` + }{ + Hostname: &hostname, + }, + Id: id, + Type: pat.PersonalAccessToken, + }, + Jsonapi: &pat.JsonApi{ + Version: "1.0", + }, + Links: &pat.SelfLink{ + Self: &pat.LinkProperty{}, + }, + }, + } +} From 33274cbfddbc4bac0e53103a80f7027e2e62f229 Mon Sep 17 00:00:00 2001 From: JSON Date: Fri, 13 Jun 2025 11:04:06 +0100 Subject: [PATCH 2/2] feat(PAT): update authenticators to support PAT (#348) --- .gitleaksignore | 3 +- go.mod | 1 - go.sum | 2 - .../personal_access_tokens/2024-03-19/gen.go | 3 + .../personal_access_tokens/2024-03-19/pat.go | 1019 +++++++++++++++++ .../2024-03-19/spec.config.yaml | 5 + .../2024-03-19/spec.yaml | 615 ++++++++++ pkg/app/app.go | 34 +- pkg/auth/authHost.go | 27 + pkg/auth/authHost_test.go | 32 + pkg/auth/oauth2authenticator.go | 40 +- pkg/auth/oauth2authenticator_test.go | 24 - pkg/auth/tokenauthenticator.go | 135 +++ pkg/auth/tokenauthenticator_test.go | 136 +++ pkg/configuration/constants.go | 2 + pkg/local_workflows/auth_workflow.go | 63 +- pkg/local_workflows/auth_workflow_test.go | 130 ++- pkg/utils/functions.go | 18 + 18 files changed, 2195 insertions(+), 94 deletions(-) create mode 100644 internal/api/personal_access_tokens/2024-03-19/gen.go create mode 100644 internal/api/personal_access_tokens/2024-03-19/pat.go create mode 100644 internal/api/personal_access_tokens/2024-03-19/spec.config.yaml create mode 100644 internal/api/personal_access_tokens/2024-03-19/spec.yaml create mode 100644 pkg/auth/authHost.go create mode 100644 pkg/auth/authHost_test.go create mode 100644 pkg/auth/tokenauthenticator_test.go diff --git a/.gitleaksignore b/.gitleaksignore index 24a082922..d427d5e7d 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -34,4 +34,5 @@ d5da1e7b3eb6676acca2021e4f3da07b1ff0d9a6:pkg/auth/oauth_test.go:jwt:30 57ac8440ab40cf8cf6eaa6600c3c5acd1b0272d2:internal/presenters/testdata/with-ignores-with-status.json:generic-api-key:224 57ac8440ab40cf8cf6eaa6600c3c5acd1b0272d2:internal/presenters/testdata/with-ignores-with-status.json:generic-api-key:474 57ac8440ab40cf8cf6eaa6600c3c5acd1b0272d2:internal/presenters/testdata/with-ignores-with-status.json:generic-api-key:539 -57ac8440ab40cf8cf6eaa6600c3c5acd1b0272d2:internal/presenters/testdata/with-ignores-with-status.json:generic-api-key:159 \ No newline at end of file +57ac8440ab40cf8cf6eaa6600c3c5acd1b0272d2:internal/presenters/testdata/with-ignores-with-status.json:generic-api-key:159 +pkg/auth/tokenauthenticator_test.go:generic-api-key:33 \ No newline at end of file diff --git a/go.mod b/go.mod index aee0d0194..4a0e345a7 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,6 @@ require ( ) require ( - github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v5 v5.0.2 github.com/go-git/go-git/v5 v5.14.0 github.com/gofrs/flock v0.12.1 diff --git a/go.sum b/go.sum index 81b3a1c9d..dd5797f9b 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,6 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= diff --git a/internal/api/personal_access_tokens/2024-03-19/gen.go b/internal/api/personal_access_tokens/2024-03-19/gen.go new file mode 100644 index 000000000..aacf7325f --- /dev/null +++ b/internal/api/personal_access_tokens/2024-03-19/gen.go @@ -0,0 +1,3 @@ +package v20240319 + +//go:generate oapi-codegen -package=v20240319 -config spec.config.yaml spec.yaml diff --git a/internal/api/personal_access_tokens/2024-03-19/pat.go b/internal/api/personal_access_tokens/2024-03-19/pat.go new file mode 100644 index 000000000..70441b024 --- /dev/null +++ b/internal/api/personal_access_tokens/2024-03-19/pat.go @@ -0,0 +1,1019 @@ +// Package v20240319 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. +package v20240319 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Defines values for PatType. +const ( + PersonalAccessToken PatType = "personal_access_token" +) + +// ActualVersion Resolved API version +type ActualVersion = string + +// Error defines model for Error. +type Error struct { + // Code An application-specific error code, expressed as a string value. + Code *string `json:"code,omitempty"` + + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Id A unique identifier for this particular occurrence of the problem. + Id *openapi_types.UUID `json:"id,omitempty"` + + // Links A link that leads to further details about this particular occurrance of the problem. + Links *ErrorLink `json:"links,omitempty"` + Meta *map[string]interface{} `json:"meta,omitempty"` + Source *struct { + // Parameter A string indicating which URI query parameter caused the error. + Parameter *string `json:"parameter,omitempty"` + + // Pointer A JSON Pointer [RFC6901] to the associated entity in the request document. + Pointer *string `json:"pointer,omitempty"` + } `json:"source,omitempty"` + + // Status The HTTP status code applicable to this problem, expressed as a string value. + Status string `json:"status"` + + // Title A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization. + Title *string `json:"title,omitempty"` +} + +// ErrorDocument defines model for ErrorDocument. +type ErrorDocument struct { + Errors []Error `json:"errors"` + Jsonapi JsonApi `json:"jsonapi"` +} + +// ErrorLink A link that leads to further details about this particular occurrance of the problem. +type ErrorLink struct { + About *LinkProperty `json:"about,omitempty"` +} + +// JsonApi defines model for JsonApi. +type JsonApi struct { + // Version Version of the JSON API specification this server supports. + Version string `json:"version"` +} + +// LinkProperty defines model for LinkProperty. +type LinkProperty struct { + union json.RawMessage +} + +// LinkProperty0 A string containing the link’s URL. +type LinkProperty0 = string + +// LinkProperty1 defines model for . +type LinkProperty1 struct { + // Href A string containing the link’s URL. + Href string `json:"href"` + + // Meta Free-form object that may contain non-standard information. + Meta *Meta `json:"meta,omitempty"` +} + +// Meta Free-form object that may contain non-standard information. +type Meta map[string]interface{} + +// PatCreatedAt Date/Time when the Personal Access Token was created. +type PatCreatedAt = time.Time + +// PatExpiresAt Date/Time when the Personal Access Token is set to expire at. +type PatExpiresAt = time.Time + +// PatId Unique identifier for the Personal Access Token. +type PatId = string + +// PatLabel Label for Personal Access Token. +type PatLabel = string + +// PatType Type of the resource. +type PatType string + +// QueryVersion Requested API version +type QueryVersion = string + +// SelfLink defines model for SelfLink. +type SelfLink struct { + Self *LinkProperty `json:"self,omitempty"` +} + +// Version Requested API version +type Version = QueryVersion + +// N400 defines model for 400. +type N400 = ErrorDocument + +// N401 defines model for 401. +type N401 = ErrorDocument + +// N403 defines model for 403. +type N403 = ErrorDocument + +// N404 defines model for 404. +type N404 = ErrorDocument + +// N500 defines model for 500. +type N500 = ErrorDocument + +// GetPatMetadataParams defines parameters for GetPatMetadata. +type GetPatMetadataParams struct { + // Version The requested version of the endpoint to process the request + Version Version `form:"version" json:"version"` +} + +// CreatePatApplicationVndAPIPlusJSONBody defines parameters for CreatePat. +type CreatePatApplicationVndAPIPlusJSONBody struct { + Data struct { + Attributes struct { + // ExpiresAt Date/Time when the Personal Access Token is set to expire at. + ExpiresAt PatExpiresAt `json:"expires_at"` + + // Label Label for Personal Access Token. + Label PatLabel `json:"label"` + } `json:"attributes"` + + // Type Type of the resource. + Type PatType `json:"type"` + } `json:"data"` +} + +// CreatePatParams defines parameters for CreatePat. +type CreatePatParams struct { + // Version The requested version of the endpoint to process the request + Version Version `form:"version" json:"version"` +} + +// CreatePatApplicationVndAPIPlusJSONRequestBody defines body for CreatePat for application/vnd.api+json ContentType. +type CreatePatApplicationVndAPIPlusJSONRequestBody CreatePatApplicationVndAPIPlusJSONBody + +// AsLinkProperty0 returns the union data inside the LinkProperty as a LinkProperty0 +func (t LinkProperty) AsLinkProperty0() (LinkProperty0, error) { + var body LinkProperty0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromLinkProperty0 overwrites any union data inside the LinkProperty as the provided LinkProperty0 +func (t *LinkProperty) FromLinkProperty0(v LinkProperty0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeLinkProperty0 performs a merge with any union data inside the LinkProperty, using the provided LinkProperty0 +func (t *LinkProperty) MergeLinkProperty0(v LinkProperty0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsLinkProperty1 returns the union data inside the LinkProperty as a LinkProperty1 +func (t LinkProperty) AsLinkProperty1() (LinkProperty1, error) { + var body LinkProperty1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromLinkProperty1 overwrites any union data inside the LinkProperty as the provided LinkProperty1 +func (t *LinkProperty) FromLinkProperty1(v LinkProperty1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeLinkProperty1 performs a merge with any union data inside the LinkProperty, using the provided LinkProperty1 +func (t *LinkProperty) MergeLinkProperty1(v LinkProperty1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t LinkProperty) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *LinkProperty) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // ListAPIVersions request + ListAPIVersions(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAPIVersion request + GetAPIVersion(ctx context.Context, version string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetPatMetadata request + GetPatMetadata(ctx context.Context, params *GetPatMetadataParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreatePatWithBody request with any body + CreatePatWithBody(ctx context.Context, params *CreatePatParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreatePatWithApplicationVndAPIPlusJSONBody(ctx context.Context, params *CreatePatParams, body CreatePatApplicationVndAPIPlusJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) ListAPIVersions(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListAPIVersionsRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAPIVersion(ctx context.Context, version string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAPIVersionRequest(c.Server, version) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetPatMetadata(ctx context.Context, params *GetPatMetadataParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetPatMetadataRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreatePatWithBody(ctx context.Context, params *CreatePatParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreatePatRequestWithBody(c.Server, params, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreatePatWithApplicationVndAPIPlusJSONBody(ctx context.Context, params *CreatePatParams, body CreatePatApplicationVndAPIPlusJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreatePatRequestWithApplicationVndAPIPlusJSONBody(c.Server, params, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewListAPIVersionsRequest generates requests for ListAPIVersions +func NewListAPIVersionsRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/openapi") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAPIVersionRequest generates requests for GetAPIVersion +func NewGetAPIVersionRequest(server string, version string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "version", runtime.ParamLocationPath, version) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/openapi/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetPatMetadataRequest generates requests for GetPatMetadata +func NewGetPatMetadataRequest(server string, params *GetPatMetadataParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/self/personal_access_token/metadata") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "version", runtime.ParamLocationQuery, params.Version); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreatePatRequestWithApplicationVndAPIPlusJSONBody calls the generic CreatePat builder with application/vnd.api+json body +func NewCreatePatRequestWithApplicationVndAPIPlusJSONBody(server string, params *CreatePatParams, body CreatePatApplicationVndAPIPlusJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreatePatRequestWithBody(server, params, "application/vnd.api+json", bodyReader) +} + +// NewCreatePatRequestWithBody generates requests for CreatePat with any type of body +func NewCreatePatRequestWithBody(server string, params *CreatePatParams, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/self/personal_access_tokens") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "version", runtime.ParamLocationQuery, params.Version); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // ListAPIVersionsWithResponse request + ListAPIVersionsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAPIVersionsResponse, error) + + // GetAPIVersionWithResponse request + GetAPIVersionWithResponse(ctx context.Context, version string, reqEditors ...RequestEditorFn) (*GetAPIVersionResponse, error) + + // GetPatMetadataWithResponse request + GetPatMetadataWithResponse(ctx context.Context, params *GetPatMetadataParams, reqEditors ...RequestEditorFn) (*GetPatMetadataResponse, error) + + // CreatePatWithBodyWithResponse request with any body + CreatePatWithBodyWithResponse(ctx context.Context, params *CreatePatParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreatePatResponse, error) + + CreatePatWithApplicationVndAPIPlusJSONBodyWithResponse(ctx context.Context, params *CreatePatParams, body CreatePatApplicationVndAPIPlusJSONRequestBody, reqEditors ...RequestEditorFn) (*CreatePatResponse, error) +} + +type ListAPIVersionsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]string + ApplicationvndApiJSON400 *N400 + ApplicationvndApiJSON401 *N401 + ApplicationvndApiJSON404 *N404 + ApplicationvndApiJSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r ListAPIVersionsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListAPIVersionsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetAPIVersionResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *map[string]interface{} + ApplicationvndApiJSON400 *N400 + ApplicationvndApiJSON401 *N401 + ApplicationvndApiJSON404 *N404 + ApplicationvndApiJSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r GetAPIVersionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAPIVersionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetPatMetadataResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationvndApiJSON200 *struct { + Data struct { + Attributes struct { + // Hostname Hostname of the environment the token belongs to. An empty string is returned if it is not associated with an environment. + Hostname *string `json:"hostname,omitempty"` + } `json:"attributes"` + + // Id Unique identifier for the Personal Access Token. + Id PatId `json:"id"` + + // Type Type of the resource. + Type PatType `json:"type"` + } `json:"data"` + Jsonapi *JsonApi `json:"jsonapi,omitempty"` + Links *SelfLink `json:"links,omitempty"` + } + ApplicationvndApiJSON400 *N400 + ApplicationvndApiJSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r GetPatMetadataResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetPatMetadataResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type CreatePatResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationvndApiJSON201 *struct { + Data struct { + Attributes struct { + // CreatedAt Date/Time when the Personal Access Token was created. + CreatedAt PatCreatedAt `json:"created_at"` + + // ExpiresAt Date/Time when the Personal Access Token is set to expire at. + ExpiresAt PatExpiresAt `json:"expires_at"` + + // Label Label for Personal Access Token. + Label PatLabel `json:"label"` + Token string `json:"token"` + } `json:"attributes"` + + // Id Unique identifier for the Personal Access Token. + Id PatId `json:"id"` + + // Type Type of the resource. + Type PatType `json:"type"` + } `json:"data"` + Jsonapi *JsonApi `json:"jsonapi,omitempty"` + Links *SelfLink `json:"links,omitempty"` + } + ApplicationvndApiJSON400 *N400 + ApplicationvndApiJSON401 *N401 + ApplicationvndApiJSON403 *N403 + ApplicationvndApiJSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r CreatePatResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreatePatResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ListAPIVersionsWithResponse request returning *ListAPIVersionsResponse +func (c *ClientWithResponses) ListAPIVersionsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAPIVersionsResponse, error) { + rsp, err := c.ListAPIVersions(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListAPIVersionsResponse(rsp) +} + +// GetAPIVersionWithResponse request returning *GetAPIVersionResponse +func (c *ClientWithResponses) GetAPIVersionWithResponse(ctx context.Context, version string, reqEditors ...RequestEditorFn) (*GetAPIVersionResponse, error) { + rsp, err := c.GetAPIVersion(ctx, version, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAPIVersionResponse(rsp) +} + +// GetPatMetadataWithResponse request returning *GetPatMetadataResponse +func (c *ClientWithResponses) GetPatMetadataWithResponse(ctx context.Context, params *GetPatMetadataParams, reqEditors ...RequestEditorFn) (*GetPatMetadataResponse, error) { + rsp, err := c.GetPatMetadata(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetPatMetadataResponse(rsp) +} + +// CreatePatWithBodyWithResponse request with arbitrary body returning *CreatePatResponse +func (c *ClientWithResponses) CreatePatWithBodyWithResponse(ctx context.Context, params *CreatePatParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreatePatResponse, error) { + rsp, err := c.CreatePatWithBody(ctx, params, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreatePatResponse(rsp) +} + +func (c *ClientWithResponses) CreatePatWithApplicationVndAPIPlusJSONBodyWithResponse(ctx context.Context, params *CreatePatParams, body CreatePatApplicationVndAPIPlusJSONRequestBody, reqEditors ...RequestEditorFn) (*CreatePatResponse, error) { + rsp, err := c.CreatePatWithApplicationVndAPIPlusJSONBody(ctx, params, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreatePatResponse(rsp) +} + +// ParseListAPIVersionsResponse parses an HTTP response from a ListAPIVersionsWithResponse call +func ParseListAPIVersionsResponse(rsp *http.Response) (*ListAPIVersionsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListAPIVersionsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []string + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest N404 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON500 = &dest + + } + + return response, nil +} + +// ParseGetAPIVersionResponse parses an HTTP response from a GetAPIVersionWithResponse call +func ParseGetAPIVersionResponse(rsp *http.Response) (*GetAPIVersionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAPIVersionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest map[string]interface{} + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest N404 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON500 = &dest + + } + + return response, nil +} + +// ParseGetPatMetadataResponse parses an HTTP response from a GetPatMetadataWithResponse call +func ParseGetPatMetadataResponse(rsp *http.Response) (*GetPatMetadataResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetPatMetadataResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Data struct { + Attributes struct { + // Hostname Hostname of the environment the token belongs to. An empty string is returned if it is not associated with an environment. + Hostname *string `json:"hostname,omitempty"` + } `json:"attributes"` + + // Id Unique identifier for the Personal Access Token. + Id PatId `json:"id"` + + // Type Type of the resource. + Type PatType `json:"type"` + } `json:"data"` + Jsonapi *JsonApi `json:"jsonapi,omitempty"` + Links *SelfLink `json:"links,omitempty"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON500 = &dest + + } + + return response, nil +} + +// ParseCreatePatResponse parses an HTTP response from a CreatePatWithResponse call +func ParseCreatePatResponse(rsp *http.Response) (*CreatePatResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreatePatResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest struct { + Data struct { + Attributes struct { + // CreatedAt Date/Time when the Personal Access Token was created. + CreatedAt PatCreatedAt `json:"created_at"` + + // ExpiresAt Date/Time when the Personal Access Token is set to expire at. + ExpiresAt PatExpiresAt `json:"expires_at"` + + // Label Label for Personal Access Token. + Label PatLabel `json:"label"` + Token string `json:"token"` + } `json:"attributes"` + + // Id Unique identifier for the Personal Access Token. + Id PatId `json:"id"` + + // Type Type of the resource. + Type PatType `json:"type"` + } `json:"data"` + Jsonapi *JsonApi `json:"jsonapi,omitempty"` + Links *SelfLink `json:"links,omitempty"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest N403 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationvndApiJSON500 = &dest + + } + + return response, nil +} diff --git a/internal/api/personal_access_tokens/2024-03-19/spec.config.yaml b/internal/api/personal_access_tokens/2024-03-19/spec.config.yaml new file mode 100644 index 000000000..e410767ef --- /dev/null +++ b/internal/api/personal_access_tokens/2024-03-19/spec.config.yaml @@ -0,0 +1,5 @@ +package: v20240319 +generate: + models: true + client: true +output: pat.go diff --git a/internal/api/personal_access_tokens/2024-03-19/spec.yaml b/internal/api/personal_access_tokens/2024-03-19/spec.yaml new file mode 100644 index 000000000..4ad4c641b --- /dev/null +++ b/internal/api/personal_access_tokens/2024-03-19/spec.yaml @@ -0,0 +1,615 @@ +# OpenAPI spec generated by vervet, DO NOT EDIT +components: + headers: + DeprecationHeader: + description: | + A header containing the deprecation date of the underlying endpoint. For more information, please refer to the deprecation header RFC: + https://tools.ietf.org/id/draft-dalal-deprecation-header-01.html + example: "2021-07-01T00:00:00Z" + schema: + format: date-time + type: string + LocationHeader: + description: | + A header providing a URL for the location of a resource + example: https://example.com/resource/4 + schema: + format: url + type: string + RequestIdResponseHeader: + description: | + A header containing a unique id used for tracking this request. If you are reporting an issue to Snyk it's very helpful to provide this ID. + example: 4b58e274-ec62-4fab-917b-1d2c48d6bdef + schema: + format: uuid + type: string + SunsetHeader: + description: | + A header containing the date of when the underlying endpoint will be removed. This header is only present if the endpoint has been deprecated. Please refer to the RFC for more information: + https://datatracker.ietf.org/doc/html/rfc8594 + example: "2021-08-02T00:00:00Z" + schema: + format: date-time + type: string + VersionRequestedResponseHeader: + description: A header containing the version of the endpoint requested by the + caller. + example: "2021-06-04" + schema: + $ref: '#/components/schemas/QueryVersion' + VersionServedResponseHeader: + description: A header containing the version of the endpoint that was served + by the API. + example: "2021-06-04" + schema: + $ref: '#/components/schemas/ActualVersion' + VersionStageResponseHeader: + description: | + A header containing the version stage of the endpoint. This stage describes the guarantees snyk provides surrounding stability of the endpoint. + schema: + enum: + - wip + - experimental + - beta + - ga + - deprecated + - sunset + example: ga + type: string + parameters: + Version: + description: The requested version of the endpoint to process the request + example: "2021-06-04" + in: query + name: version + required: true + schema: + $ref: '#/components/schemas/QueryVersion' + responses: + "400": + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' + description: 'Bad Request: A parameter provided as a part of the request was + invalid.' + headers: + deprecation: + $ref: '#/components/headers/DeprecationHeader' + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + snyk-version-lifecycle-stage: + $ref: '#/components/headers/VersionStageResponseHeader' + snyk-version-requested: + $ref: '#/components/headers/VersionRequestedResponseHeader' + snyk-version-served: + $ref: '#/components/headers/VersionServedResponseHeader' + sunset: + $ref: '#/components/headers/SunsetHeader' + "401": + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' + description: 'Unauthorized: the request requires an authentication token.' + headers: + deprecation: + $ref: '#/components/headers/DeprecationHeader' + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + snyk-version-lifecycle-stage: + $ref: '#/components/headers/VersionStageResponseHeader' + snyk-version-requested: + $ref: '#/components/headers/VersionRequestedResponseHeader' + snyk-version-served: + $ref: '#/components/headers/VersionServedResponseHeader' + sunset: + $ref: '#/components/headers/SunsetHeader' + "403": + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' + description: 'Forbidden: the request requires an authentication token with more + or different permissions.' + headers: + deprecation: + $ref: '#/components/headers/DeprecationHeader' + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + snyk-version-lifecycle-stage: + $ref: '#/components/headers/VersionStageResponseHeader' + snyk-version-requested: + $ref: '#/components/headers/VersionRequestedResponseHeader' + snyk-version-served: + $ref: '#/components/headers/VersionServedResponseHeader' + sunset: + $ref: '#/components/headers/SunsetHeader' + "404": + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' + description: 'Not Found: The resource being operated on could not be found.' + headers: + deprecation: + $ref: '#/components/headers/DeprecationHeader' + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + snyk-version-lifecycle-stage: + $ref: '#/components/headers/VersionStageResponseHeader' + snyk-version-requested: + $ref: '#/components/headers/VersionRequestedResponseHeader' + snyk-version-served: + $ref: '#/components/headers/VersionServedResponseHeader' + sunset: + $ref: '#/components/headers/SunsetHeader' + "500": + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' + description: 'Internal Server Error: An error was encountered while attempting + to process the request.' + headers: + deprecation: + $ref: '#/components/headers/DeprecationHeader' + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + snyk-version-lifecycle-stage: + $ref: '#/components/headers/VersionStageResponseHeader' + snyk-version-requested: + $ref: '#/components/headers/VersionRequestedResponseHeader' + snyk-version-served: + $ref: '#/components/headers/VersionServedResponseHeader' + sunset: + $ref: '#/components/headers/SunsetHeader' + schemas: + ActualVersion: + description: Resolved API version + pattern: ^((([0-9]{4})-([0-1][0-9]))-((3[01])|(0[1-9])|([12][0-9]))(~(wip|work-in-progress|experimental|beta))?)$ + type: string + Error: + additionalProperties: false + example: + detail: Not Found + status: "404" + properties: + code: + description: An application-specific error code, expressed as a string value. + example: entity-not-found + type: string + detail: + description: A human-readable explanation specific to this occurrence of + the problem. + example: 'The request was missing these required fields: ...' + type: string + id: + description: A unique identifier for this particular occurrence of the problem. + example: f16c31b5-6129-4571-add8-d589da9be524 + format: uuid + type: string + links: + $ref: '#/components/schemas/ErrorLink' + meta: + additionalProperties: true + example: + key: value + type: object + source: + additionalProperties: false + example: + pointer: /data/attributes + properties: + parameter: + description: A string indicating which URI query parameter caused the + error. + example: param1 + type: string + pointer: + description: A JSON Pointer [RFC6901] to the associated entity in the + request document. + example: /data/attributes + type: string + type: object + status: + description: The HTTP status code applicable to this problem, expressed + as a string value. + example: "400" + pattern: ^[45]\d\d$ + type: string + title: + description: A short, human-readable summary of the problem that SHOULD + NOT change from occurrence to occurrence of the problem, except for purposes + of localization. + example: Bad request + type: string + required: + - status + - detail + type: object + ErrorDocument: + additionalProperties: false + example: + errors: + - detail: Permission denied for this resource + status: "403" + jsonapi: + version: "1.0" + properties: + errors: + example: + - detail: Permission denied for this resource + status: "403" + items: + $ref: '#/components/schemas/Error' + minItems: 1 + type: array + jsonapi: + $ref: '#/components/schemas/JsonApi' + required: + - jsonapi + - errors + type: object + ErrorLink: + additionalProperties: false + description: A link that leads to further details about this particular occurrance + of the problem. + example: + about: https://example.com/about_this_error + properties: + about: + $ref: '#/components/schemas/LinkProperty' + type: object + JsonApi: + additionalProperties: false + example: + version: "1.0" + properties: + version: + description: Version of the JSON API specification this server supports. + example: "1.0" + pattern: ^(0|[1-9]\d*)\.(0|[1-9]\d*)$ + type: string + required: + - version + type: object + LinkProperty: + example: https://example.com/api/resource + oneOf: + - description: A string containing the link’s URL. + example: https://example.com/api/resource + type: string + - additionalProperties: false + example: + href: https://example.com/api/resource + properties: + href: + description: A string containing the link’s URL. + example: https://example.com/api/resource + type: string + meta: + $ref: '#/components/schemas/Meta' + required: + - href + type: object + Meta: + additionalProperties: true + description: Free-form object that may contain non-standard information. + example: + key1: value1 + key2: + sub_key: sub_value + key3: + - array_value1 + - array_value2 + type: object + PatCreatedAt: + description: Date/Time when the Personal Access Token was created. + example: "2024-07-01T00:00:00Z" + format: date-time + type: string + PatExpiresAt: + description: Date/Time when the Personal Access Token is set to expire at. + example: "2024-07-01T00:00:00Z" + format: date-time + type: string + PatId: + description: Unique identifier for the Personal Access Token. + example: 01ARZ3NDEKTSV4RRFFQ69G5FAV + format: ulid + type: string + PatLabel: + description: Label for Personal Access Token. + maxLength: 60 + minLength: 1 + type: string + PatType: + description: Type of the resource. + enum: + - personal_access_token + type: string + QueryVersion: + description: Requested API version + pattern: ^(wip|work-in-progress|experimental|beta|((([0-9]{4})-([0-1][0-9]))-((3[01])|(0[1-9])|([12][0-9]))(~(wip|work-in-progress|experimental|beta))?))$ + type: string + SelfLink: + additionalProperties: false + example: + self: https://example.com/api/this_resource + properties: + self: + $ref: '#/components/schemas/LinkProperty' + type: object +info: + title: pat-service + version: 3.0.0 +openapi: 3.0.3 +paths: + /openapi: + get: + description: List available versions of OpenAPI specification + operationId: listAPIVersions + responses: + "200": + content: + application/json: + schema: + items: + type: string + type: array + description: List of available versions is returned + headers: + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + "400": + $ref: '#/components/responses/400' + "401": + $ref: '#/components/responses/401' + "404": + $ref: '#/components/responses/404' + "500": + $ref: '#/components/responses/500' + tags: + - OpenAPI + /openapi/{version}: + get: + description: Get OpenAPI specification effective at version. + operationId: getAPIVersion + parameters: + - description: The requested version of the API + in: path + name: version + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + type: object + description: OpenAPI specification matching requested version is returned + headers: + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + snyk-version-requested: + $ref: '#/components/headers/VersionRequestedResponseHeader' + snyk-version-served: + $ref: '#/components/headers/VersionServedResponseHeader' + "400": + $ref: '#/components/responses/400' + "401": + $ref: '#/components/responses/401' + "404": + $ref: '#/components/responses/404' + "500": + $ref: '#/components/responses/500' + tags: + - OpenAPI + /self/personal_access_token/metadata: + get: + description: Get Personal Access Token metadata. + operationId: getPatMetadata + parameters: + - $ref: '#/components/parameters/Version' + responses: + "200": + content: + application/vnd.api+json: + schema: + additionalProperties: false + properties: + data: + additionalProperties: false + properties: + attributes: + additionalProperties: false + properties: + hostname: + description: Hostname of the environment the token belongs + to. An empty string is returned if it is not associated + with an environment. + maxLength: 255 + type: string + type: object + id: + $ref: '#/components/schemas/PatId' + type: + $ref: '#/components/schemas/PatType' + required: + - type + - id + - attributes + type: object + jsonapi: + $ref: '#/components/schemas/JsonApi' + links: + $ref: '#/components/schemas/SelfLink' + required: + - data + type: object + description: Return Personal Access Token metadata. + headers: + deprecation: + $ref: '#/components/headers/DeprecationHeader' + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + snyk-version-lifecycle-stage: + $ref: '#/components/headers/VersionStageResponseHeader' + snyk-version-requested: + $ref: '#/components/headers/VersionRequestedResponseHeader' + snyk-version-served: + $ref: '#/components/headers/VersionServedResponseHeader' + sunset: + $ref: '#/components/headers/SunsetHeader' + "400": + $ref: '#/components/responses/400' + "500": + $ref: '#/components/responses/500' + summary: Get Personal Access Token metadata. + tags: + - PersonalAccessToken + x-cerberus: + authentication: + skip: true + authorization: + skip: true + x-snyk-api-owners: + - '@snyk/access' + x-snyk-api-releases: + - "2024-03-19" + x-snyk-api-version: "2024-03-19" + x-snyk-api-resource: pats + x-snyk-resource-singleton: true + /self/personal_access_tokens: + post: + description: Create Personal Access Token. + operationId: createPat + parameters: + - $ref: '#/components/parameters/Version' + requestBody: + content: + application/vnd.api+json: + schema: + additionalProperties: false + properties: + data: + additionalProperties: false + properties: + attributes: + additionalProperties: false + properties: + expires_at: + $ref: '#/components/schemas/PatExpiresAt' + label: + $ref: '#/components/schemas/PatLabel' + required: + - label + - expires_at + type: object + type: + $ref: '#/components/schemas/PatType' + required: + - type + - attributes + type: object + required: + - data + type: object + responses: + "201": + content: + application/vnd.api+json: + schema: + properties: + data: + additionalProperties: false + properties: + attributes: + additionalProperties: false + properties: + created_at: + $ref: '#/components/schemas/PatCreatedAt' + expires_at: + $ref: '#/components/schemas/PatExpiresAt' + label: + $ref: '#/components/schemas/PatLabel' + token: + pattern: snyk_(uat|sat)\.[a-z0-9]{8}\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+ + type: string + required: + - token + - label + - created_at + - expires_at + type: object + id: + $ref: '#/components/schemas/PatId' + type: + $ref: '#/components/schemas/PatType' + required: + - type + - id + - attributes + type: object + jsonapi: + $ref: '#/components/schemas/JsonApi' + links: + $ref: '#/components/schemas/SelfLink' + required: + - data + type: object + description: Return newly created Personal Access Token. + headers: + deprecation: + $ref: '#/components/headers/DeprecationHeader' + location: + $ref: '#/components/headers/LocationHeader' + snyk-request-id: + $ref: '#/components/headers/RequestIdResponseHeader' + snyk-version-lifecycle-stage: + $ref: '#/components/headers/VersionStageResponseHeader' + snyk-version-requested: + $ref: '#/components/headers/VersionRequestedResponseHeader' + snyk-version-served: + $ref: '#/components/headers/VersionServedResponseHeader' + sunset: + $ref: '#/components/headers/SunsetHeader' + "400": + $ref: '#/components/responses/400' + "401": + $ref: '#/components/responses/401' + "403": + $ref: '#/components/responses/403' + "500": + $ref: '#/components/responses/500' + summary: Create Personal Access Token. + tags: + - PersonalAccessToken + x-cerberus: + authorization: + skip: true + featureFlag: + values: + - personalAccessTokens + x-snyk-api-owners: + - '@snyk/access' + x-snyk-api-releases: + - "2024-03-19" + x-snyk-api-version: "2024-03-19" + x-snyk-api-resource: pats +servers: +- description: pat-service + url: /hidden +tags: +- description: The OpenAPI specification for pat-service. + name: OpenAPI +- description: Personal access tokens + name: PersonalAccessToken +x-cerberus: + authentication: + strategies: + - InternalJWT: {} +x-snyk-api-lifecycle: released +x-snyk-api-version: "2024-03-19" \ No newline at end of file diff --git a/pkg/app/app.go b/pkg/app/app.go index a53e50e4b..e1a187c3a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -77,7 +77,7 @@ func defaultFuncOrganization(engine workflow.Engine, config configuration.Config return callback } -func defaultFuncApiUrl(config configuration.Configuration, logger *zerolog.Logger) configuration.DefaultValueFunction { +func defaultFuncApiUrl(engine workflow.Engine, config configuration.Configuration, logger *zerolog.Logger) configuration.DefaultValueFunction { callback := func(existingValue interface{}) (interface{}, error) { urlString := constants.SNYK_DEFAULT_API_URL @@ -88,7 +88,20 @@ func defaultFuncApiUrl(config configuration.Configuration, logger *zerolog.Logge if len(urlFromOauthToken) > 0 && len(urlFromOauthToken[0]) > 0 { urlString = urlFromOauthToken[0] - } else if existingValue != nil { // configured value takes precedence + } else if auth.IsAuthTypePAT(config.GetString(configuration.AUTHENTICATION_TOKEN)) { + regionUrls := config.GetStringSlice(configuration.SNYK_REGION_URLS) + // prefer the existing value + if val, ok := existingValue.(string); ok { + regionUrls = []string{val} + } + apiUrl, endpointErr := auth.DeriveEndpointFromPAT(config.GetString(configuration.AUTHENTICATION_TOKEN), config, engine.GetNetworkAccess().GetUnauthorizedHttpClient(), regionUrls) + if endpointErr != nil { + logger.Warn().Err(endpointErr).Msg("failed to get api url from pat") + } + if len(apiUrl) > 0 { + urlString = apiUrl + } + } else if existingValue != nil { // try the configured value as last resort if temp, ok := existingValue.(string); ok { urlString = temp } @@ -188,6 +201,19 @@ func defaultMaxNetworkRetryAttempts(engine workflow.Engine) configuration.Defaul return callback } +func defaultRegionUrls() configuration.DefaultValueFunction { + callback := func(existingValue interface{}) (interface{}, error) { + if existingValue != nil { + if existingString, ok := existingValue.(string); ok { + return strings.Split(existingString, ","), nil + } + } + + return []string{constants.SNYK_DEFAULT_API_URL}, nil + } + return callback +} + // initConfiguration initializes the configuration with initial values. func initConfiguration(engine workflow.Engine, config configuration.Configuration, logger *zerolog.Logger, apiClientFactory func(url string, client *http.Client) api.ApiClient) { if logger == nil { @@ -213,9 +239,11 @@ func initConfiguration(engine workflow.Engine, config configuration.Configuratio config.AddDefaultValue(presenters.CONFIG_JSON_STRIP_WHITESPACES, configuration.StandardDefaultValueFunction(true)) config.AddDefaultValue(auth.CONFIG_KEY_ALLOWED_HOST_REGEXP, configuration.StandardDefaultValueFunction(`^api(\.(.+))?\.snyk|snykgov\.io$`)) + config.AddDefaultValue(configuration.SNYK_REGION_URLS, defaultRegionUrls()) + // set default filesize threshold to 512MB config.AddDefaultValue(configuration.IN_MEMORY_THRESHOLD_BYTES, configuration.StandardDefaultValueFunction(constants.SNYK_DEFAULT_IN_MEMORY_THRESHOLD_MB)) - config.AddDefaultValue(configuration.API_URL, defaultFuncApiUrl(config, logger)) + config.AddDefaultValue(configuration.API_URL, defaultFuncApiUrl(engine, config, logger)) config.AddDefaultValue(configuration.TEMP_DIR_PATH, defaultTempDirectory(engine, config, logger)) config.AddDefaultValue(configuration.WEB_APP_URL, func(existingValue any) (any, error) { diff --git a/pkg/auth/authHost.go b/pkg/auth/authHost.go new file mode 100644 index 000000000..7308698a2 --- /dev/null +++ b/pkg/auth/authHost.go @@ -0,0 +1,27 @@ +package auth + +import ( + "net/url" + "strings" + + "github.com/snyk/go-application-framework/internal/api" +) + +func redirectAuthHost(instance string) (string, error) { + // handle both cases if instance is a URL or just a host + if !strings.HasPrefix(instance, "http") { + instance = "https://" + instance + } + + instanceUrl, err := url.Parse(instance) + if err != nil { + return "", err + } + + canonicalizedInstanceUrl, err := api.GetCanonicalApiAsUrl(*instanceUrl) + if err != nil { + return "", err + } + + return canonicalizedInstanceUrl.Host, nil +} diff --git a/pkg/auth/authHost_test.go b/pkg/auth/authHost_test.go new file mode 100644 index 000000000..672543eb0 --- /dev/null +++ b/pkg/auth/authHost_test.go @@ -0,0 +1,32 @@ +package auth + +import ( + "testing" + + "github.com/snyk/go-application-framework/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func Test_isValidAuthHost(t *testing.T) { + testCases := []struct { + authHost string + expected bool + }{ + {"api.au.snyk.io", true}, + {"api.example.snyk.io", true}, + {"api.snyk.io", true}, + {"api.snykgov.io", true}, + {"api.pre-release.snykgov.io", true}, + {"snyk.io", false}, + {"api.example.com", false}, + } + + for _, tc := range testCases { + actual, err := utils.MatchesRegex(tc.authHost, `^api(\.(.+))?\.snyk|snykgov\.io$`) + assert.NoError(t, err) + + if actual != tc.expected { + t.Errorf("isValidAuthHost(%q) = %v, want %v", tc.authHost, actual, tc.expected) + } + } +} diff --git a/pkg/auth/oauth2authenticator.go b/pkg/auth/oauth2authenticator.go index 8a6231c5a..ac3d4f0a7 100644 --- a/pkg/auth/oauth2authenticator.go +++ b/pkg/auth/oauth2authenticator.go @@ -15,8 +15,7 @@ import ( "net" "net/http" "net/url" - "regexp" - "strings" + "sync" "time" @@ -25,8 +24,8 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" - "github.com/snyk/go-application-framework/internal/api" "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/utils" ) const ( @@ -40,6 +39,7 @@ const ( AUTHENTICATED_MESSAGE = "Your account has been authenticated." PARAMETER_CLIENT_ID string = "client-id" PARAMETER_CLIENT_SECRET string = "client-secret" + AUTH_TYPE_OAUTH = "oauth" ) type GrantType int @@ -463,7 +463,7 @@ func (o *oAuth2Authenticator) modifyTokenUrl(responseInstance string) error { redirectAuthHostRE := o.config.GetString(CONFIG_KEY_ALLOWED_HOST_REGEXP) o.logger.Info().Msgf("Validating with regexp: \"%s\"", redirectAuthHostRE) - isValidHost, err := isValidAuthHost(authHost, redirectAuthHostRE) + isValidHost, err := utils.MatchesRegex(authHost, redirectAuthHostRE) if err != nil { return err } @@ -491,38 +491,6 @@ func (o *oAuth2Authenticator) modifyTokenUrl(responseInstance string) error { return nil } -func redirectAuthHost(instance string) (string, error) { - // handle both cases if instance is a URL or just a host - if !strings.HasPrefix(instance, "http") { - instance = "https://" + instance - } - - instanceUrl, err := url.Parse(instance) - if err != nil { - return "", err - } - - canonicalizedInstanceUrl, err := api.GetCanonicalApiAsUrl(*instanceUrl) - if err != nil { - return "", err - } - - return canonicalizedInstanceUrl.Host, nil -} - -func isValidAuthHost(authHost string, hostRegularExpression string) (bool, error) { - if len(hostRegularExpression) == 0 { - return false, fmt.Errorf("regular expression to check host names must not be empty") - } - - r, err := regexp.Compile(hostRegularExpression) - if err != nil { - return false, err - } - - return r.MatchString(authHost), nil -} - func (o *oAuth2Authenticator) AddAuthenticationHeader(request *http.Request) error { if request == nil { return fmt.Errorf("request must not be nil") diff --git a/pkg/auth/oauth2authenticator_test.go b/pkg/auth/oauth2authenticator_test.go index abb6b8836..164d89e1d 100644 --- a/pkg/auth/oauth2authenticator_test.go +++ b/pkg/auth/oauth2authenticator_test.go @@ -357,30 +357,6 @@ func Test_Authenticate_CredentialsGrant(t *testing.T) { assert.NotEmpty(t, token) } -func Test_isValidAuthHost(t *testing.T) { - testCases := []struct { - authHost string - expected bool - }{ - {"api.au.snyk.io", true}, - {"api.example.snyk.io", true}, - {"api.snyk.io", true}, - {"api.snykgov.io", true}, - {"api.pre-release.snykgov.io", true}, - {"snyk.io", false}, - {"api.example.com", false}, - } - - for _, tc := range testCases { - actual, err := isValidAuthHost(tc.authHost, `^api(\.(.+))?\.snyk|snykgov\.io$`) - assert.NoError(t, err) - - if actual != tc.expected { - t.Errorf("isValidAuthHost(%q) = %v, want %v", tc.authHost, actual, tc.expected) - } - } -} - func Test_Authenticate_AuthorizationCode(t *testing.T) { t.Run("happy", func(t *testing.T) { config := configuration.NewWithOpts() diff --git a/pkg/auth/tokenauthenticator.go b/pkg/auth/tokenauthenticator.go index 7554cf90f..f2abae445 100644 --- a/pkg/auth/tokenauthenticator.go +++ b/pkg/auth/tokenauthenticator.go @@ -1,8 +1,33 @@ package auth import ( + "context" + "errors" "fmt" "net/http" + "regexp" + "slices" + "strings" + + "github.com/google/uuid" + snykError "github.com/snyk/error-catalog-golang-public/snyk" + patAPI "github.com/snyk/go-application-framework/internal/api/personal_access_tokens/2024-03-19" + "github.com/snyk/go-application-framework/internal/constants" + "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/utils" +) + +const ( + AUTH_TYPE_TOKEN = "token" + AUTH_TYPE_PAT = "pat" + CACHED_PAT_KEY_PREFIX = "cached_pat" + CACHED_PAT_IS_VALID_KEY_PREFIX = "cached_pat_is_valid" + CONFIG_KEY_TOKEN = "api" // the snyk config key for api token + CONFIG_KEY_ENDPOINT = "endpoint" // the snyk config key for api endpoint +) + +const ( + patAPIVersion = "2024-03-19" ) var _ Authenticator = (*tokenAuthenticator)(nil) @@ -38,3 +63,113 @@ func (t *tokenAuthenticator) AddAuthenticationHeader(request *http.Request) erro func (t *tokenAuthenticator) IsSupported() bool { return true } + +func IsAuthTypeToken(token string) bool { + if _, uuidErr := uuid.Parse(token); uuidErr == nil { + return true + } + return false +} + +func IsAuthTypePAT(token string) bool { + // e.g. snyk_uat.1a2b3c4d.mySuperSecret_Token-Value.aChecksum_123-Value + patRegex := `^snyk_(?:uat|sat)\.[a-z0-9]{8}\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+$` + if matched, err := regexp.MatchString(patRegex, token); err == nil && matched { + return matched + } + return false +} + +type DeriveEndpointFn func(pat string, config configuration.Configuration, client *http.Client, regions []string) (string, error) + +var _ DeriveEndpointFn = DeriveEndpointFromPAT + +// DeriveEndpointFromPAT iterates a list of Snyk region URLs and tries to make an authenticated request to the Snyk PAT API +// on success, it will return the correct Snyk endpoint from the given PAT +func DeriveEndpointFromPAT(pat string, config configuration.Configuration, client *http.Client, regionUrls []string) (string, error) { + var ( + err error + errs error + endpoint string + ) + + // include the default region as a fallback + if !slices.Contains(regionUrls, constants.SNYK_DEFAULT_API_URL) { + regionUrls = append(regionUrls, constants.SNYK_DEFAULT_API_URL) + } + + for _, url := range regionUrls { + endpoint, err = deriveEndpoint(pat, config, client, url) + if err != nil { + errs = errors.Join(errs, err) + continue + } + break + } + + if errs != nil && len(endpoint) == 0 { + return "", errs + } + return endpoint, nil +} + +// deriveEndpoint makes an authenticated request to the Snyk PAT API and returns the correct Snyk endpoint from the given PAT +func deriveEndpoint(token string, config configuration.Configuration, client *http.Client, snykRegionUrl string) (string, error) { + apiBaseUrl := snykRegionUrl + if len(apiBaseUrl) == 0 { + apiBaseUrl = constants.SNYK_DEFAULT_API_URL + } + + if !strings.HasPrefix(apiBaseUrl, "hidden") { + apiBaseUrl = fmt.Sprintf("%s/hidden", apiBaseUrl) + } + + patApiClient, err := patAPI.NewClientWithResponses(apiBaseUrl, patAPI.WithHTTPClient(client)) + if err != nil { + return "", fmt.Errorf("failed to create PAT API client: %w", err) + } + + params := &patAPI.GetPatMetadataParams{ + Version: patAPIVersion, + } + + reqEditors := []patAPI.RequestEditorFn{ + func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + return nil + }, + } + + resp, err := patApiClient.GetPatMetadataWithResponse(context.Background(), params, reqEditors...) + if err != nil { + return "", snykError.NewUnauthorisedError("failed to validate PAT; either missing or invalid PAT") + } + + if resp.StatusCode() != http.StatusOK { + return "", snykError.NewUnauthorisedError(fmt.Sprintf("failed to get PAT metadata (status: %d): %s", resp.StatusCode(), string(resp.Body))) + } + + patMetadataBody := resp.ApplicationvndApiJSON200 + if patMetadataBody == nil || patMetadataBody.Data.Attributes.Hostname == nil { + return "", fmt.Errorf("failed to decode PAT metadata response or missing hostname") + } + + hostname := *patMetadataBody.Data.Attributes.Hostname + if hostname == "" { + return "", fmt.Errorf("invalid empty hostname") + } + + authHost, err := redirectAuthHost(hostname) + if err != nil { + return "", err + } + + validHostRegex := config.GetString(CONFIG_KEY_ALLOWED_HOST_REGEXP) + if isValid, err := utils.MatchesRegex(authHost, validHostRegex); err != nil || !isValid { + return "", fmt.Errorf("invalid hostname: %s", authHost) + } + + endpoint := fmt.Sprintf("https://%s", authHost) + + return endpoint, nil +} diff --git a/pkg/auth/tokenauthenticator_test.go b/pkg/auth/tokenauthenticator_test.go new file mode 100644 index 000000000..28bc6754e --- /dev/null +++ b/pkg/auth/tokenauthenticator_test.go @@ -0,0 +1,136 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + pat "github.com/snyk/go-application-framework/internal/api/personal_access_tokens/2024-03-19" + "github.com/snyk/go-application-framework/pkg/configuration" +) + +func TestIsAuthTypeToken(t *testing.T) { + assert.True(t, IsAuthTypeToken("f47ac10b-58cc-4372-a567-0e02b2c3d479")) + // PAT format + assert.False(t, IsAuthTypeToken("snyk_uat.12345678.abcdefg-hijklmnop.qrstuvwxyz-123456")) +} + +func TestIsAuthTypePAT(t *testing.T) { + assert.True(t, IsAuthTypePAT("snyk_uat.12345678.abcdefg-hijklmnop.qrstuvwxyz-123456")) + // legacy token format + assert.False(t, IsAuthTypePAT("f47ac10b-58cc-4372-a567-0e02b2c3d479")) +} + +func TestDeriveEndpointFromPAT(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/hidden/self/personal_access_token/metadata" { + w.Header().Set("Content-Type", "application/vnd.api+json") + hostname := "" + w.WriteHeader(http.StatusOK) + authorization := r.Header.Get("Authorization") + + if authorization == "token valid_pat" { + hostname = "api.snyk.io" + } else if authorization == "token valid_eu_pat" { + hostname = "api.eu.snyk.io" + } else if authorization == "token invalid_pat" { + hostname = "invalid.hostname.io" + } else if authorization == "token empty_pat" { + hostname = "" + } + + res := createPatMetadataReponse(t, hostname) + err := json.NewEncoder(w).Encode(res.ApplicationvndApiJSON200) + assert.NoError(t, err) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + config := configuration.NewWithOpts() + config.Set(CONFIG_KEY_ALLOWED_HOST_REGEXP, `^api(\.(.+))?\.snyk\.io$`) + client := server.Client() + + t.Run("Valid PAT", func(t *testing.T) { + endpoint, err := DeriveEndpointFromPAT("valid_pat", config, client, []string{server.URL}) + assert.NoError(t, err) + assert.Equal(t, "https://api.snyk.io", endpoint) + }) + + t.Run("Multiple regions", func(t *testing.T) { + endpoint, err := DeriveEndpointFromPAT("valid_pat", config, client, []string{"https://someRandomUrl.com", server.URL, "https://someOtherRandomUrl.com"}) + assert.NoError(t, err) + assert.Equal(t, "https://api.snyk.io", endpoint) + }) + + t.Run("Valid EU PAT", func(t *testing.T) { + endpoint, err := DeriveEndpointFromPAT("valid_eu_pat", config, client, []string{server.URL}) + assert.NoError(t, err) + assert.Equal(t, "https://api.eu.snyk.io", endpoint) + }) + + t.Run("Bad URL", func(t *testing.T) { + _, err := DeriveEndpointFromPAT("empty_pat", config, client, []string{"https://someRandomUrl.com"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Authentication error") + }) + + t.Run("Unauthorized PAT", func(t *testing.T) { + _, err := DeriveEndpointFromPAT("invalid_pat", config, client, []string{server.URL}) + expectedError := "invalid hostname: api.invalid.hostname.io" + assert.ErrorContains(t, err, expectedError) + }) + + t.Run("Empty Hostname in Response", func(t *testing.T) { + _, err := DeriveEndpointFromPAT("empty_pat", config, client, []string{server.URL}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid empty hostname") + }) +} + +func createPatMetadataReponse(t *testing.T, hostname string) *pat.GetPatMetadataResponse { + t.Helper() + + id := "someRandomId" + return &pat.GetPatMetadataResponse{ + Body: []byte{}, + HTTPResponse: nil, + ApplicationvndApiJSON200: &struct { + Data struct { + Attributes struct { + Hostname *string `json:"hostname,omitempty"` + } `json:"attributes"` + Id pat.PatId `json:"id"` + Type pat.PatType `json:"type"` + } `json:"data"` + Jsonapi *pat.JsonApi `json:"jsonapi,omitempty"` + Links *pat.SelfLink `json:"links,omitempty"` + }{ + Data: struct { + Attributes struct { + Hostname *string `json:"hostname,omitempty"` + } `json:"attributes"` + Id pat.PatId `json:"id"` + Type pat.PatType `json:"type"` + }{ + Attributes: struct { + Hostname *string `json:"hostname,omitempty"` + }{ + Hostname: &hostname, + }, + Id: id, + Type: pat.PersonalAccessToken, + }, + Jsonapi: &pat.JsonApi{ + Version: "1.0", + }, + Links: &pat.SelfLink{ + Self: &pat.LinkProperty{}, + }, + }, + } +} diff --git a/pkg/configuration/constants.go b/pkg/configuration/constants.go index 2a1ae5d00..488e7f63c 100644 --- a/pkg/configuration/constants.go +++ b/pkg/configuration/constants.go @@ -47,6 +47,8 @@ const ( PREVIEW_FEATURES_ENABLED string = "internal_preview_features_enabled" // boolean indicates if preview features shall be enabled UNKNOWN_ARGS string = "internal_unknown_arguments" // arguments unknown to the current application but maybe relevant for delegated application calls IN_MEMORY_THRESHOLD_BYTES string = "internal_in_memory_threshold_bytes" // threshold to determine where to store workflow.Data + SNYK_REGION_URLS string = "internal_snyk_region_urls" // comma delimited string of snyk region URLs e.g. `https://api.snyk.io,https://api.eu.snyk.io' + // feature flags FF_OAUTH_AUTH_FLOW_ENABLED string = "internal_snyk_oauth_enabled" FF_CODE_CONSISTENT_IGNORES string = "internal_snyk_code_ignores_enabled" diff --git a/pkg/local_workflows/auth_workflow.go b/pkg/local_workflows/auth_workflow.go index 5def2953a..25edcf0b3 100644 --- a/pkg/local_workflows/auth_workflow.go +++ b/pkg/local_workflows/auth_workflow.go @@ -5,12 +5,12 @@ import ( "os" "strings" - "github.com/google/uuid" "github.com/rs/zerolog" "github.com/spf13/pflag" "github.com/snyk/go-application-framework/pkg/auth" "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/ui" pgk_utils "github.com/snyk/go-application-framework/pkg/utils" "github.com/snyk/go-application-framework/pkg/workflow" ) @@ -19,11 +19,9 @@ const ( workflowNameAuth = "auth" headlessFlag = "headless" authTypeParameter = "auth-type" - authTypeOAuth = "oauth" - authTypeToken = "token" ) -var authTypeDescription = fmt.Sprint("Authentication type (", authTypeToken, ", ", authTypeOAuth, ")") +var authTypeDescription = fmt.Sprint("Authentication type (", auth.AUTH_TYPE_TOKEN, ", ", auth.AUTH_TYPE_OAUTH, ")") const templateConsoleMessage = ` Now redirecting you to our auth page, go ahead and log in, @@ -71,26 +69,33 @@ func authEntryPoint(invocationCtx workflow.InvocationContext, _ []workflow.Data) auth.WithLogger(logger), ) - err = entryPointDI(invocationCtx, logger, engine, authenticator) + err = entryPointDI(invocationCtx, logger, engine, authenticator, auth.DeriveEndpointFromPAT) return nil, err } func autoDetectAuthType(config configuration.Configuration) string { // testing if an API token was specified token := config.GetString(ConfigurationNewAuthenticationToken) - if _, uuidErr := uuid.Parse(token); uuidErr == nil { - return authTypeToken + if len(token) > 0 && auth.IsAuthTypeToken(token) { + return auth.AUTH_TYPE_TOKEN } + // currently the auth workflow defaults to traditional token auth when an IDE environment is detected + // this will need to change if IDEs want to invoke this workflow for oauth/PAT integration := config.GetString(configuration.INTEGRATION_NAME) if pgk_utils.IsSnykIde(integration) { - return authTypeToken + return auth.AUTH_TYPE_TOKEN } - return authTypeOAuth + // check if auth type is PAT + if len(token) > 0 && auth.IsAuthTypePAT(token) { + return auth.AUTH_TYPE_PAT + } + + return auth.AUTH_TYPE_OAUTH } -func entryPointDI(invocationCtx workflow.InvocationContext, logger *zerolog.Logger, engine workflow.Engine, authenticator auth.Authenticator) (err error) { +func entryPointDI(invocationCtx workflow.InvocationContext, logger *zerolog.Logger, engine workflow.Engine, authenticator auth.Authenticator, deriveEndpointFn auth.DeriveEndpointFn) (err error) { analytics := invocationCtx.GetAnalytics() config := invocationCtx.GetConfiguration() @@ -102,7 +107,7 @@ func entryPointDI(invocationCtx workflow.InvocationContext, logger *zerolog.Logg logger.Printf("Authentication Type: %s", authType) analytics.AddExtensionStringValue(authTypeParameter, authType) - if strings.EqualFold(authType, authTypeOAuth) { // OAUTH flow + if strings.EqualFold(authType, auth.AUTH_TYPE_OAUTH) { // OAUTH flow logger.Printf("Unset legacy token key %q from config", configuration.AUTHENTICATION_TOKEN) config.Unset(configuration.AUTHENTICATION_TOKEN) @@ -114,7 +119,41 @@ func entryPointDI(invocationCtx workflow.InvocationContext, logger *zerolog.Logg return err } - fmt.Println(auth.AUTHENTICATED_MESSAGE) + err = ui.DefaultUi().Output(auth.AUTHENTICATED_MESSAGE) + if err != nil { + logger.Debug().Err(err).Msg("Failed to output authenticated message") + } + } else if strings.EqualFold(authType, auth.AUTH_TYPE_PAT) { // PAT flow + pat := config.GetString(ConfigurationNewAuthenticationToken) + + logger.Printf("Unset oauth key %q from config", auth.CONFIG_KEY_OAUTH_TOKEN) + config.Unset(auth.CONFIG_KEY_OAUTH_TOKEN) + config.Set(configuration.AUTHENTICATION_TOKEN, "") // clear token to avoid using it during authentication + + regions := config.GetStringSlice(configuration.SNYK_REGION_URLS) + // prefer the configured API URL + if config.IsSet(configuration.API_URL) { + regions = []string{config.GetString(configuration.API_URL)} + } + apiUrl, endpointErr := deriveEndpointFn(pat, config, invocationCtx.GetNetworkAccess().GetUnauthorizedHttpClient(), regions) + + if endpointErr != nil { + return endpointErr + } + + if len(apiUrl) > 0 { + logger.Print("Set pat credentials in config") + engine.GetConfiguration().PersistInStorage(auth.CONFIG_KEY_TOKEN) + engine.GetConfiguration().PersistInStorage(auth.CONFIG_KEY_ENDPOINT) + + engine.GetConfiguration().Set(auth.CONFIG_KEY_TOKEN, pat) + engine.GetConfiguration().Set(auth.CONFIG_KEY_ENDPOINT, apiUrl) + + err = ui.DefaultUi().Output(auth.AUTHENTICATED_MESSAGE) + if err != nil { + logger.Debug().Err(err).Msg("Failed to output authenticated message") + } + } } else { // LEGACY flow logger.Printf("Unset oauth key %q from config", auth.CONFIG_KEY_OAUTH_TOKEN) config.Unset(auth.CONFIG_KEY_OAUTH_TOKEN) diff --git a/pkg/local_workflows/auth_workflow_test.go b/pkg/local_workflows/auth_workflow_test.go index 54898de18..6f1cf64a5 100644 --- a/pkg/local_workflows/auth_workflow_test.go +++ b/pkg/local_workflows/auth_workflow_test.go @@ -2,7 +2,9 @@ package localworkflows import ( "bytes" + "errors" "fmt" + "net/http" "testing" "github.com/golang/mock/gomock" @@ -10,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/snyk/go-application-framework/pkg/analytics" + "github.com/snyk/go-application-framework/pkg/auth" "github.com/snyk/go-application-framework/pkg/configuration" "github.com/snyk/go-application-framework/pkg/mocks" ) @@ -17,13 +20,12 @@ import ( func Test_auth_oauth(t *testing.T) { mockCtl := gomock.NewController(t) logContent := &bytes.Buffer{} - config := configuration.NewInMemory() + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) logger := zerolog.New(logContent) engine := mocks.NewMockEngine(mockCtl) authenticator := mocks.NewMockAuthenticator(mockCtl) analytics := analytics.New() config.Set(configuration.PREVIEW_FEATURES_ENABLED, true) - engine.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) engine.EXPECT().Init().Times(1) @@ -33,6 +35,10 @@ func Test_auth_oauth(t *testing.T) { err = engine.Init() assert.NoError(t, err) + mockDeriveEndpoint := func(pat string, config configuration.Configuration, client *http.Client, regions []string) (string, error) { + return "", nil + } + t.Run("happy", func(t *testing.T) { config.Set(authTypeParameter, nil) authenticator.EXPECT().Authenticate().Times(2).Return(nil) @@ -40,8 +46,8 @@ func Test_auth_oauth(t *testing.T) { mockInvocationContext.EXPECT().GetConfiguration().Return(config).AnyTimes() mockInvocationContext.EXPECT().GetEnhancedLogger().Return(&logger).AnyTimes() mockInvocationContext.EXPECT().GetAnalytics().Return(analytics).AnyTimes() - err = entryPointDI(mockInvocationContext, &logger, engine, authenticator) - err = entryPointDI(mockInvocationContext, &logger, engine, authenticator) + err = entryPointDI(mockInvocationContext, &logger, engine, authenticator, mockDeriveEndpoint) + err = entryPointDI(mockInvocationContext, &logger, engine, authenticator, mockDeriveEndpoint) assert.NoError(t, err) }) @@ -53,7 +59,7 @@ func Test_auth_oauth(t *testing.T) { mockInvocationContext.EXPECT().GetConfiguration().Return(config).AnyTimes() mockInvocationContext.EXPECT().GetEnhancedLogger().Return(&logger).AnyTimes() mockInvocationContext.EXPECT().GetAnalytics().Return(analytics).AnyTimes() - err = entryPointDI(mockInvocationContext, &logger, engine, authenticator) + err = entryPointDI(mockInvocationContext, &logger, engine, authenticator, mockDeriveEndpoint) assert.Equal(t, expectedErr, err) }) } @@ -61,11 +67,14 @@ func Test_auth_oauth(t *testing.T) { func Test_auth_token(t *testing.T) { mockCtl := gomock.NewController(t) logContent := &bytes.Buffer{} - config := configuration.NewInMemory() + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) analytics := analytics.New() logger := zerolog.New(logContent) engine := mocks.NewMockEngine(mockCtl) authenticator := mocks.NewMockAuthenticator(mockCtl) + mockDeriveEndpoint := func(pat string, config configuration.Configuration, client *http.Client, regions []string) (string, error) { + return "", nil + } engine.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) engine.EXPECT().Init().Times(1) @@ -77,13 +86,14 @@ func Test_auth_token(t *testing.T) { assert.NoError(t, err) t.Run("happy", func(t *testing.T) { - config.Set(authTypeParameter, authTypeToken) + config.Set(authTypeParameter, auth.AUTH_TYPE_TOKEN) engine.EXPECT().InvokeWithConfig(gomock.Any(), gomock.Any()) mockInvocationContext := mocks.NewMockInvocationContext(mockCtl) mockInvocationContext.EXPECT().GetConfiguration().Return(config).AnyTimes() mockInvocationContext.EXPECT().GetEnhancedLogger().Return(&logger).AnyTimes() mockInvocationContext.EXPECT().GetAnalytics().Return(analytics).AnyTimes() - err = entryPointDI(mockInvocationContext, &logger, engine, authenticator) + + err = entryPointDI(mockInvocationContext, &logger, engine, authenticator, mockDeriveEndpoint) assert.NoError(t, err) }) @@ -95,33 +105,123 @@ func Test_auth_token(t *testing.T) { mockInvocationContext.EXPECT().GetConfiguration().Return(config).AnyTimes() mockInvocationContext.EXPECT().GetEnhancedLogger().Return(&logger).AnyTimes() mockInvocationContext.EXPECT().GetAnalytics().Return(analytics).AnyTimes() - err = entryPointDI(mockInvocationContext, &logger, engine, authenticator) + + err = entryPointDI(mockInvocationContext, &logger, engine, authenticator, mockDeriveEndpoint) assert.NoError(t, err) }) } +func Test_pat(t *testing.T) { + const ( + testPAT = "snyk_pat.12345678.abcdefghijklmnopqrstuvwxyz123456" + mockedPatEndpoint = "https://api.snyk.io" + expectedAPIKeyStorage = auth.CONFIG_KEY_TOKEN + ) + + mockCtl := gomock.NewController(t) + defer mockCtl.Finish() + + logContent := &bytes.Buffer{} + logger := zerolog.New(logContent) + analytics := analytics.New() + + engine := mocks.NewMockEngine(mockCtl) + authenticator := mocks.NewMockAuthenticator(mockCtl) + mockNetworkAccess := mocks.NewMockNetworkAccess(mockCtl) + mockHTTPClient := &http.Client{} + + t.Run("happy", func(t *testing.T) { + config := configuration.New() + config.Set(authTypeParameter, auth.AUTH_TYPE_PAT) + config.Set(ConfigurationNewAuthenticationToken, testPAT) + config.Set(auth.CONFIG_KEY_OAUTH_TOKEN, "some-oauth-token") + config.Set(configuration.AUTHENTICATION_TOKEN, "some-legacy-api-token") + + config.Set(configuration.SNYK_REGION_URLS, []string{"https://api.snyk.io"}) + + mockInvocationContext := mocks.NewMockInvocationContext(mockCtl) + mockInvocationContext.EXPECT().GetConfiguration().Return(config).AnyTimes() + mockInvocationContext.EXPECT().GetEnhancedLogger().Return(&logger).AnyTimes() + mockInvocationContext.EXPECT().GetAnalytics().Return(analytics).Times(1) + mockInvocationContext.EXPECT().GetNetworkAccess().Return(mockNetworkAccess).Times(1) + mockNetworkAccess.EXPECT().GetUnauthorizedHttpClient().Return(mockHTTPClient).Times(1) + + engineConfig := configuration.New() + engine.EXPECT().GetConfiguration().Return(engineConfig).AnyTimes() + + mockDeriveEndpoint := func(pat string, config configuration.Configuration, client *http.Client, regions []string) (string, error) { + return mockedPatEndpoint, nil + } + + err := entryPointDI(mockInvocationContext, &logger, engine, authenticator, mockDeriveEndpoint) + assert.NoError(t, err) + + assert.Empty(t, config.GetString(auth.CONFIG_KEY_OAUTH_TOKEN)) + assert.Empty(t, config.GetString(configuration.AUTHENTICATION_TOKEN)) + }) + + t.Run("DeriveEndpointFromPAT fails", func(t *testing.T) { + config := configuration.New() + config.Set(authTypeParameter, auth.AUTH_TYPE_PAT) + config.Set(ConfigurationNewAuthenticationToken, testPAT) + originalToken := "original-token" + config.Set(expectedAPIKeyStorage, originalToken) + + config.Set(configuration.SNYK_REGION_URLS, []string{"https://api.snyk.io"}) + + mockInvocationContext := mocks.NewMockInvocationContext(mockCtl) + mockInvocationContext.EXPECT().GetConfiguration().Return(config).AnyTimes() + mockInvocationContext.EXPECT().GetEnhancedLogger().Return(&logger).AnyTimes() + mockInvocationContext.EXPECT().GetAnalytics().Return(analytics).Times(1) + mockInvocationContext.EXPECT().GetNetworkAccess().Return(mockNetworkAccess).Times(1) + mockNetworkAccess.EXPECT().GetUnauthorizedHttpClient().Return(mockHTTPClient).Times(1) + + engineConfig := configuration.New() + engine.EXPECT().GetConfiguration().Return(engineConfig).AnyTimes() + + expectedErr := errors.New("mocked DeriveEndpointFromPAT error") + mockDeriveEndpoint := func(pat string, config configuration.Configuration, client *http.Client, regions []string) (string, error) { + return "", expectedErr + } + + err := entryPointDI(mockInvocationContext, &logger, engine, authenticator, mockDeriveEndpoint) + assert.Error(t, err) + assert.ErrorIs(t, err, expectedErr) + + assert.Equal(t, originalToken, config.GetString(expectedAPIKeyStorage)) + }) +} + func Test_autodetectAuth(t *testing.T) { t.Run("in stable versions, token by default", func(t *testing.T) { - expected := authTypeOAuth - config := configuration.NewInMemory() + expected := auth.AUTH_TYPE_OAUTH + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) config.Set(configuration.PREVIEW_FEATURES_ENABLED, false) actual := autoDetectAuthType(config) assert.Equal(t, expected, actual) }) t.Run("token for IDEs", func(t *testing.T) { - expected := authTypeToken - config := configuration.NewInMemory() + expected := auth.AUTH_TYPE_TOKEN + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) config.Set(configuration.INTEGRATION_NAME, "VS_CODE") actual := autoDetectAuthType(config) assert.Equal(t, expected, actual) }) t.Run("token for given API token", func(t *testing.T) { - expected := authTypeToken - config := configuration.NewInMemory() + expected := auth.AUTH_TYPE_TOKEN + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) config.Set(ConfigurationNewAuthenticationToken, "00000000-0000-0000-0000-000000000000") actual := autoDetectAuthType(config) assert.Equal(t, expected, actual) }) + + t.Run("token for given PAT", func(t *testing.T) { + expected := auth.AUTH_TYPE_PAT + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + config.Set(ConfigurationNewAuthenticationToken, "snyk_uat.12345678.abcdefg-hijklmnop.qrstuvwxyz-123456") + actual := autoDetectAuthType(config) + assert.Equal(t, expected, actual) + }) } diff --git a/pkg/utils/functions.go b/pkg/utils/functions.go index 1e6fb4155..0e6ebe8a7 100644 --- a/pkg/utils/functions.go +++ b/pkg/utils/functions.go @@ -1,5 +1,10 @@ package utils +import ( + "fmt" + "regexp" +) + // ErrorOf is used to wrap a function call and extract the error out of it // For example, in this code function Foo returns an error, // @@ -15,3 +20,16 @@ package utils func ErrorOf(_ any, err error) error { return err } func ValueOf[T any](value T, _ error) T { return value } + +func MatchesRegex(inputString string, regex string) (bool, error) { + if len(regex) == 0 { + return false, fmt.Errorf("regular expression must not be empty") + } + + r, err := regexp.Compile(regex) + if err != nil { + return false, err + } + + return r.MatchString(inputString), nil +}