From 75923a4073d76bccd2c805fe0bf7fe8979de6607 Mon Sep 17 00:00:00 2001 From: Philip Galea <> Date: Tue, 25 Jun 2024 21:32:03 +0100 Subject: [PATCH 1/9] Adding pagination to apps API call to support more than 200 amazon_aws applications --- internal/webssoauth/webssoauth.go | 147 +++++++++++++++++++++--------- 1 file changed, 105 insertions(+), 42 deletions(-) diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 52483fd..67f9acf 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -806,60 +806,123 @@ func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization // an error that is related having multiple fed apps available. Requires // assoicated OIDC app has been granted okta.apps.read to its scope. func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken) (apps []*okta.Application, err error) { - apiURL, err := url.Parse(fmt.Sprintf("https://%s/api/v1/apps", w.config.OrgDomain())) - if err != nil { - return nil, err - } - params := url.Values{} - params.Add("limit", "200") - params.Add("q", amazonAWS) - params.Add("filter", `status eq "ACTIVE"`) - apiURL.RawQuery = params.Encode() - req, err := http.NewRequest(http.MethodGet, apiURL.String(), nil) - if err != nil { - return nil, err - } - req.Header.Add(accept, utils.ApplicationJSON) - req.Header.Add(utils.ContentType, utils.ApplicationJSON) - req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) - req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) - req.Header.Add("Authorization", fmt.Sprintf("%s %s", at.TokenType, at.AccessToken)) - resp, err := w.config.HTTPClient().Do(req) - if resp.StatusCode == http.StatusForbidden { - return nil, err - } + apps = make([]*okta.Application, 0) - // Any errors after this point should be considered related to when the OIDC - // app can read multiple fed apps - if err != nil || resp.StatusCode != http.StatusOK { - return nil, newMultipleFedAppsError(err) - } + var afterValue string = "" + var req *http.Request = nil + var resp *http.Response = nil + var apiURL *url.URL = nil + var oktaApps []okta.Application = nil - var oktaApps []okta.Application - err = json.NewDecoder(resp.Body).Decode(&oktaApps) - if err != nil { - return nil, newMultipleFedAppsError(err) - } + for { - apps = make([]*okta.Application, 0) - for i, app := range oktaApps { - if app.Name != amazonAWS { - continue + apiURL, err = url.Parse(fmt.Sprintf("https://%s/api/v1/apps", w.config.OrgDomain())) + + if err != nil { + return nil, err } - if app.Status != "ACTIVE" { - continue + + params := url.Values{} + params.Add("limit", "200") + params.Add("q", amazonAWS) + params.Add("filter", `status eq "ACTIVE"`) + + if len(afterValue) > 0 { + params.Add("after", afterValue) } - if app.Settings.App.WebSSOClientID != clientID { - continue + + apiURL.RawQuery = params.Encode() + + req, err = http.NewRequest(http.MethodGet, apiURL.String(), nil) + + if err != nil { + return nil, err + } + + req.Header.Add(accept, utils.ApplicationJSON) + req.Header.Add(utils.ContentType, utils.ApplicationJSON) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) + req.Header.Add("Authorization", fmt.Sprintf("%s %s", at.TokenType, at.AccessToken)) + + resp, err = w.config.HTTPClient().Do(req) + + if resp.StatusCode == http.StatusForbidden { + return nil, err + } + + // Any errors after this point should be considered related to when the OIDC + // app can read multiple fed apps + if err != nil || resp.StatusCode != http.StatusOK { + return nil, newMultipleFedAppsError(err) + } + + err = json.NewDecoder(resp.Body).Decode(&oktaApps) + if err != nil { + return nil, newMultipleFedAppsError(err) + } + + for i, app := range oktaApps { + if app.Name != amazonAWS { + continue + } + if app.Status != "ACTIVE" { + continue + } + if app.Settings.App.WebSSOClientID != clientID { + continue + } + oa := oktaApps[i] + apps = append(apps, &oa) + } + + // Extract the next URL from the Link header + afterValue = w.extractAfter(resp) + + if len(afterValue) == 0 { + if w.config.Debug() { + w.consolePrint(" no more pages to read\n") + } + break } - oa := oktaApps[i] - apps = append(apps, &oa) + } return } +// Extract the after token from page of results +func (w *WebSSOAuthentication) extractAfter(resp *http.Response) (href string) { + + linkHeaders := resp.Header["Link"] + for _, value := range linkHeaders { + if strings.Contains(value, `rel="next"`) { + + parts := strings.Split(strings.TrimSpace(value), ";") + if len(parts) < 2 { + continue + } + + urlPart := strings.Trim(parts[0], "<>") + relPart := strings.TrimSpace(parts[1]) + + if relPart == `rel="next"` { + urlPart := strings.Trim(urlPart, `"`) + + nextURL, err := url.Parse(urlPart) + if err != nil { + return "" + } else { + return nextURL.Query().Get("after") + } + } + } + } + + return "" +} + // accessToken see: // https://developer.okta.com/docs/reference/api/oidc/#token func (w *WebSSOAuthentication) accessToken(deviceAuth *okta.DeviceAuthorization) (at *okta.AccessToken, err error) { From 7d37b391875921f7fbe425e5b07fa1f05e1693bd Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 2 Jul 2024 10:54:06 -0700 Subject: [PATCH 2/9] Pagination client similar to old okta-sdk-golang client. --- go.mod | 5 +- go.sum | 2 + internal/paginator/paginator.go | 209 ++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 internal/paginator/paginator.go diff --git a/go.mod b/go.mod index fbf234a..e07bbfa 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,10 @@ require ( gopkg.in/yaml.v2 v2.4.0 ) -require golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect +require ( + github.com/BurntSushi/toml v1.4.0 // indirect + golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect +) require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 61df305..e1940e8 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw= github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= diff --git a/internal/paginator/paginator.go b/internal/paginator/paginator.go new file mode 100644 index 0000000..f18db47 --- /dev/null +++ b/internal/paginator/paginator.go @@ -0,0 +1,209 @@ +package paginator + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/BurntSushi/toml" +) + +type PaginateResponse struct { + *http.Response + pgntr *Paginator + Self string + NextPage string +} + +func (r *PaginateResponse) HasNextPage() bool { + return r.NextPage != "" +} + +func (r *PaginateResponse) Next(v interface{}) (*PaginateResponse, error) { + req, err := http.NewRequest(http.MethodGet, r.NextPage, nil) + for k, v := range *r.pgntr.headers { + req.Header.Add(k, v) + } + if err != nil { + return nil, err + } + return r.pgntr.Do(req, v) +} + +type Paginator struct { + httpClient *http.Client + url *url.URL + headers *map[string]string + params *map[string]string +} + +func NewPaginator(httpClient *http.Client, url *url.URL, headers *map[string]string, params *map[string]string) *Paginator { + pgntr := Paginator{ + httpClient: httpClient, + url: url, + headers: headers, + params: params, + } + return &pgntr +} + +func (pgntr *Paginator) Do(req *http.Request, v interface{}) (*PaginateResponse, error) { + resp, err := pgntr.httpClient.Do(req) + if err != nil { + return nil, err + } + return buildPaginateResponse(resp, pgntr, &v) +} + +func (pgntr *Paginator) GetItems(v interface{}) (resp *PaginateResponse, err error) { + params := url.Values{} + for k, v := range *pgntr.params { + params.Add(k, v) + } + pgntr.url.RawQuery = params.Encode() + + req, err := http.NewRequest(http.MethodGet, pgntr.url.String(), nil) + if err != nil { + return + } + for k, v := range *pgntr.headers { + req.Header.Add(k, v) + } + + resp, err = pgntr.Do(req, v) + return +} + +func newPaginateResponse(r *http.Response, pgntr *Paginator) *PaginateResponse { + response := &PaginateResponse{Response: r, pgntr: pgntr} + links := r.Header["Link"] + + if len(links) == 0 { + return response + + } + for _, link := range links { + splitLinkHeader := strings.Split(link, ";") + if len(splitLinkHeader) < 2 { + continue + } + linkStr := strings.TrimRight(strings.TrimLeft(splitLinkHeader[0], "<"), ">") + if urlURL, err := url.Parse(linkStr); err == nil { + if r.Request != nil { + q := r.Request.URL.Query() + for k, v := range urlURL.Query() { + q.Set(k, v[0]) + } + urlURL.RawQuery = q.Encode() + } + if strings.Contains(link, `rel="self"`) { + response.Self = urlURL.String() + } + if strings.Contains(link, `rel="next"`) { + response.NextPage = urlURL.String() + } + } + } + + return response +} + +func buildPaginateResponse(resp *http.Response, pgntr *Paginator, v interface{}) (*PaginateResponse, error) { + ct := resp.Header.Get("Content-Type") + response := newPaginateResponse(resp, pgntr) + err := checkResponseForError(resp) + if err != nil { + return response, err + } + bodyBytes, _ := io.ReadAll(resp.Body) + copyBodyBytes := make([]byte, len(bodyBytes)) + copy(copyBodyBytes, bodyBytes) + _ = resp.Body.Close() // close it to avoid memory leaks + resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // restore the original response body + if len(copyBodyBytes) == 0 { + return response, nil + } + switch { + case strings.Contains(ct, "application/xml"): + err = xml.NewDecoder(bytes.NewReader(copyBodyBytes)).Decode(v) + case strings.Contains(ct, "application/json"): + err = json.NewDecoder(bytes.NewReader(copyBodyBytes)).Decode(v) + case strings.Contains(ct, "application/octet-stream"): + // since the response is arbitrary binary data, we leave it to the user to decode it + return response, nil + default: + return nil, errors.New("could not build a response for type: " + ct) + } + if err == io.EOF { + err = nil + } + if err != nil { + return nil, err + } + return response, nil +} + +func checkResponseForError(resp *http.Response) error { + statusCode := resp.StatusCode + if statusCode >= http.StatusOK && statusCode < http.StatusBadRequest { + return nil + } + e := Error{} + if (statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden) && + strings.Contains(resp.Header.Get("Www-Authenticate"), "Bearer") { + for _, v := range strings.Split(resp.Header.Get("Www-Authenticate"), ", ") { + if strings.Contains(v, "error_description") { + _, err := toml.Decode(v, &e) + if err != nil { + e.ErrorSummary = "unauthorized" + } + return &e + } + } + } + bodyBytes, _ := io.ReadAll(resp.Body) + copyBodyBytes := make([]byte, len(bodyBytes)) + copy(copyBodyBytes, bodyBytes) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + _ = json.NewDecoder(bytes.NewReader(copyBodyBytes)).Decode(&e) + if statusCode == http.StatusInternalServerError { + e.ErrorSummary += fmt.Sprintf(", x-okta-request-id=%s", resp.Header.Get("x-okta-request-id")) + } + return &e +} + +type Error struct { + ErrorMessage string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorSummary string `json:"errorSummary,omitempty" toml:"error_description"` + ErrorLink string `json:"errorLink,omitempty"` + ErrorId string `json:"errorId,omitempty"` + ErrorCauses []map[string]interface{} `json:"errorCauses,omitempty"` +} + +func (e *Error) Error() string { + formattedErr := "the API returned an unknown error" + if e.ErrorDescription != "" { + formattedErr = fmt.Sprintf("the API returned an error: %s", e.ErrorDescription) + } else if e.ErrorSummary != "" { + formattedErr = fmt.Sprintf("the API returned an error: %s", e.ErrorSummary) + } + if len(e.ErrorCauses) > 0 { + var causes []string + for _, cause := range e.ErrorCauses { + for key, val := range cause { + causes = append(causes, fmt.Sprintf("%s: %v", key, val)) + } + } + formattedErr = fmt.Sprintf("%s. Causes: %s", formattedErr, strings.Join(causes, ", ")) + } + return formattedErr +} From b1f72b833d07e892310a41632581a0cd73da199e Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 2 Jul 2024 10:55:37 -0700 Subject: [PATCH 3/9] List apps with pagination client. Closes #212 --- internal/webssoauth/webssoauth.go | 149 +++++++++--------------------- 1 file changed, 43 insertions(+), 106 deletions(-) diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 67f9acf..dc2dd6e 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -55,6 +55,7 @@ import ( "github.com/okta/okta-aws-cli/internal/exec" "github.com/okta/okta-aws-cli/internal/okta" "github.com/okta/okta-aws-cli/internal/output" + "github.com/okta/okta-aws-cli/internal/paginator" "github.com/okta/okta-aws-cli/internal/utils" ) @@ -805,122 +806,58 @@ func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization // after getting anything other than a 403 on /api/v1/apps will be wrapped as as // an error that is related having multiple fed apps available. Requires // assoicated OIDC app has been granted okta.apps.read to its scope. -func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken) (apps []*okta.Application, err error) { - - apps = make([]*okta.Application, 0) - - var afterValue string = "" - var req *http.Request = nil - var resp *http.Response = nil - var apiURL *url.URL = nil - var oktaApps []okta.Application = nil - - for { - - apiURL, err = url.Parse(fmt.Sprintf("https://%s/api/v1/apps", w.config.OrgDomain())) - - if err != nil { - return nil, err - } - - params := url.Values{} - params.Add("limit", "200") - params.Add("q", amazonAWS) - params.Add("filter", `status eq "ACTIVE"`) - - if len(afterValue) > 0 { - params.Add("after", afterValue) - } - - apiURL.RawQuery = params.Encode() - - req, err = http.NewRequest(http.MethodGet, apiURL.String(), nil) +func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken) ([]*okta.Application, error) { + apiURL, err := url.Parse(fmt.Sprintf("https://%s/api/v1/apps", w.config.OrgDomain())) + if err != nil { + return nil, err + } + headers := map[string]string{ + accept: utils.ApplicationJSON, + utils.ContentType: utils.ApplicationJSON, + utils.UserAgentHeader: config.UserAgentValue, + utils.XOktaAWSCLIOperationHeader: utils.XOktaAWSCLIWebOperation, + "Authorization": fmt.Sprintf("%s %s", at.TokenType, at.AccessToken), + } + params := map[string]string{ + "limit": "200", + "q": amazonAWS, + "filter": `status eq "ACTIVE"`, + } + pgntr := paginator.NewPaginator(w.config.HTTPClient(), apiURL, &headers, ¶ms) + allApps := make([]*okta.Application, 0) + resp, err := pgntr.GetItems(&allApps) + // TODO fall back to /api/v1/users/{userId}/appLinks if 403 / http.StatusForbidden + if err != nil { + return nil, err + } + for resp.HasNextPage() { + var nextApps []*okta.Application + resp, err = resp.Next(&nextApps) if err != nil { return nil, err } + allApps = append(allApps, nextApps...) + } - req.Header.Add(accept, utils.ApplicationJSON) - req.Header.Add(utils.ContentType, utils.ApplicationJSON) - req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) - req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) - req.Header.Add("Authorization", fmt.Sprintf("%s %s", at.TokenType, at.AccessToken)) - - resp, err = w.config.HTTPClient().Do(req) - - if resp.StatusCode == http.StatusForbidden { - return nil, err - } - - // Any errors after this point should be considered related to when the OIDC - // app can read multiple fed apps - if err != nil || resp.StatusCode != http.StatusOK { - return nil, newMultipleFedAppsError(err) - } - - err = json.NewDecoder(resp.Body).Decode(&oktaApps) - if err != nil { - return nil, newMultipleFedAppsError(err) - } - - for i, app := range oktaApps { - if app.Name != amazonAWS { - continue - } - if app.Status != "ACTIVE" { - continue - } - if app.Settings.App.WebSSOClientID != clientID { - continue - } - oa := oktaApps[i] - apps = append(apps, &oa) + apps := make([]*okta.Application, 0) + for _, app := range allApps { + // even though the query was for AWS fed apps check just in case + if app.Name != amazonAWS { + continue } - - // Extract the next URL from the Link header - afterValue = w.extractAfter(resp) - - if len(afterValue) == 0 { - if w.config.Debug() { - w.consolePrint(" no more pages to read\n") - } - break + // even though the query filted on active status check just in case + if app.Status != "ACTIVE" { + continue } - - } - - return -} - -// Extract the after token from page of results -func (w *WebSSOAuthentication) extractAfter(resp *http.Response) (href string) { - - linkHeaders := resp.Header["Link"] - for _, value := range linkHeaders { - if strings.Contains(value, `rel="next"`) { - - parts := strings.Split(strings.TrimSpace(value), ";") - if len(parts) < 2 { - continue - } - - urlPart := strings.Trim(parts[0], "<>") - relPart := strings.TrimSpace(parts[1]) - - if relPart == `rel="next"` { - urlPart := strings.Trim(urlPart, `"`) - - nextURL, err := url.Parse(urlPart) - if err != nil { - return "" - } else { - return nextURL.Query().Get("after") - } - } + // only apps that that have client the web sso client + if app.Settings.App.WebSSOClientID != clientID { + continue } + apps = append(apps, app) } - return "" + return apps, nil } // accessToken see: From 96911da181a7af8fe062d9a849f7f565f32010dc Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 2 Jul 2024 13:16:28 -0700 Subject: [PATCH 4/9] Seamless support for non-admin uses via the `GET /api/v1/users/me/appLinks` endpoint. Closes #66 --- README.md | 58 +++++++++++++++---------- internal/okta/application.go | 12 +++++- internal/paginator/paginator.go | 6 ++- internal/webssoauth/webssoauth.go | 70 +++++++++++++++++++++++++++---- 4 files changed, 110 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index f6e82ce..b817fa4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ other tools accessing the AWS API. There are two primary commands of operation: authorization. `okta-aws-cli web` is native to the Okta Identity Engine and its authentication and device authorization flows. `okta-aws-cli web` is not compatible with Okta Classic orgs. `okta-aws-cli m2m` makes use of private key -(OAuth2) authorization and OIDC. +(OAuth2) authorization and OIDC. ```shell # *nix, export statements @@ -20,11 +20,6 @@ export AWS_ACCESS_KEY_ID=ASIAUJHVCS6UQC52NOL7 export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY export AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5T... - # *nix, eval export ENV vars into current shell -$ eval `okta-aws-cli web --oidc-client-id 0oabc --org-domain my-org.okta.com` && aws s3 ls -2018-04-04 11:56:00 test-bucket -2021-06-10 12:47:11 mah-bucket - rem Windows setx statements C:\> okta-aws-cli web --oidc-client-id 0oabc --org-domain my-org.okta.com SETX AWS_ACCESS_KEY_ID ASIAUJHVCS6UQC52NOL7 @@ -92,13 +87,13 @@ authorization at the Okta web site. After that the human returns to the CLI they select an identity provider and a role from that IdP. Web command is an integration that pairs an Okta [OIDC Native -Application](https://developer.okta.com/blog/2021/11/12/native-sso) with an +Application](https://developer.okta.com/blog/2021/11/12/native-sso) with an [Okta AWS Federation integration application](https://www.okta.com/integrations/aws-account-federation/). In turn the Okta AWS Fed app is itself paired with an [AWS IAM identity provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create.html). The Okta AWS Fed app is SAML based and the Okta AWS CLI interacts with AWS IAM -using +using [AssumeRoleWithSAML](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithSAML.html). `okta-aws-cli web` handles authentication through Okta and presents a SAML @@ -131,10 +126,9 @@ at `Applications > [the OIDC app] > General Settings > Grant type`. If [Multiple AWS environments](#multiple-aws-environments) (see below) are to be supported by a single OIDC application, the OIDC app must have the - `okta.apps.read` grant. Apps read and other application grants are configured - at `Applications > [the OIDC app] > Okta API Scopes` in the Okta Admin UI. - *NOTE*: the Okta Management API only supports the `okta.apps.read` grant for - admin users at this time (see ["Non-Admin Users"](#non-admin-users)). + `okta.apps.read` grant for admin users and `okta.users.read.self` for non-admin + users. Application grants are configured at `Applications > [the OIDC app] > + Okta API Scopes` in the Okta Admin UI. The pairing with the AWS Federation Application is achieved in the Fed app's Sign On Settings. These settings are in the Okta Admin UI at `Applications > [the @@ -156,14 +150,11 @@ URL below. Then follow the directions in that wizard. #### Multiple AWS environments -**NOTE**: Multiple AWS environments works correctly without extra configuration -for admin users. See ["Non-Admin Users"](#non-admin-users) for extra -configuration needed for non-admin users. - To support multiple AWS environments, associate additional AWS Federation -applications with the OIDC app The OIDC app **must** have the `okta.apps.read` -grant. The following is an illustration of the association of objects that make -up this kind of configuration. +applications with an OIDC app. The OIDC app **must** have the `okta.apps.read` +grant to support admin users. To support non-admin users the OIDC app **must** +have the `okta.users.read.self` grant. The following is an illustration of the +association of objects that make up this kind of configuration. ![okta-aws-cli supporting multiple AWS environments](./doc/multi-aws-environments.jpg) @@ -174,6 +165,18 @@ up this kind of configuration. #### Non-Admin Users +The CLI will work for non-admin users if the OIDC Native app is granted the +`okta.users.read.self` scope. The API endpoint `GET /api/v1/users/me/appLinks` +is referenced to discover which applications are assigned to the non-admin user. + +**IMPORTANT!!!** + +Below is a deprecated recommendation for non-admin users. We are leaving it in +the README for legacy purposes. We are no longer recommending this workaround so +long as the OIDC app is granted the `okta.users.read.self` scope. + +**OLD work around for non-admin users** + Multiple AWS environments requires extra configuration for non-admin users. Follow these steps to support non-admin users. @@ -621,7 +624,9 @@ have equivalent policies if not share the same policy. If the AWS Federation app has more stringent assurance requirements than the OIDC app a `400 Bad Request` API error is likely to occur. -Note: In authentication policy rule of AWS Federation app, **Device State** must be set to **Any** for using Okta AWS CLI. Other options are not supported at this time. +**NOTE**: In authentication policy rule of AWS Federation app, **Device State** +must be set to **Any** for using Okta AWS CLI. Other options are not supported +at this time. ## Operation @@ -682,12 +687,16 @@ $ eval `okta-aws-cli` $ aws s3 ls 2018-04-04 11:56:00 test-bucket 2021-06-10 12:47:11 mah-bucket + +$ okta-aws-cli web --oidc-client-id 0oabc --org-domain my-org.okta.com --exec -- aws s3 ls s3://example + PRE aaa/ +2023-03-08 16:01:01 4 a.log ``` ### AWS credentials file orientated usage -**NOTE**: example assumes other Okta AWS CLI configuration values have already been -set by ENV variables or `.env` file. +**NOTE**: example assumes other Okta AWS CLI configuration values have already +been set by ENV variables or `.env` file. ```shell $ okta-aws-cli web --oidc-client-id 0oabc --org-domain my-org.okta.com --profile test --format aws-credentials && \ @@ -704,7 +713,10 @@ Wrote profile "test" to /Users/mikemondragon/.aws/credentials 2018-04-04 11:56:00 test-bucket 2021-06-10 12:47:11 mah-bucket ``` -**NOTE**: Writing to the AWS credentials file will include the `x_security_token_expires` value in RFC3339 format. This allows tools dependent on valid AWS credentials to validate if they are expired or not, and potentially trigger a refresh if needed. +**NOTE**: Writing to the AWS credentials file will include the +*`x_security_token_expires` value in RFC3339 format. This allows tools dependent +*on valid AWS credentials to validate if they are expired or not, and +*potentially trigger a refresh if needed. **NOTE**: the Okta AWS CLI will only append to the AWS credentials file. Be sure to comment out or remove previous named profiles from the credentials file. diff --git a/internal/okta/application.go b/internal/okta/application.go index 103e376..37389d7 100644 --- a/internal/okta/application.go +++ b/internal/okta/application.go @@ -16,8 +16,8 @@ package okta -// Application Okta API application object -// See: https://developer.okta.com/docs/reference/api/apps/#application-object +// Application Okta API application object. +// See: https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Application/#tag/Application/operation/listApplications type Application struct { ID string `json:"id"` Label string `json:"label"` @@ -30,3 +30,11 @@ type Application struct { } `json:"app"` } `json:"settings"` } + +// ApplicationLink Okta API application link object. +// See: https://developer.okta.com/docs/api/openapi/okta-management/management/tag/User/#tag/User/operation/listAppLinks +type ApplicationLink struct { + ID string `json:"appInstanceId"` + Label string `json:"label"` + Name string `json:"appName"` +} diff --git a/internal/paginator/paginator.go b/internal/paginator/paginator.go index f18db47..f2e4a72 100644 --- a/internal/paginator/paginator.go +++ b/internal/paginator/paginator.go @@ -63,8 +63,10 @@ func (pgntr *Paginator) Do(req *http.Request, v interface{}) (*PaginateResponse, func (pgntr *Paginator) GetItems(v interface{}) (resp *PaginateResponse, err error) { params := url.Values{} - for k, v := range *pgntr.params { - params.Add(k, v) + if pgntr.params != nil { + for k, v := range *pgntr.params { + params.Add(k, v) + } } pgntr.url.RawQuery = params.Encode() diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index dc2dd6e..1f7998d 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -184,6 +184,7 @@ func (w *WebSSOAuthentication) EstablishIAMCredentials() error { apps, err = w.listFedApps(clientID, at) if at != nil && err != nil { + w.consolePrint("Listing federation apps failed first time, retrying ...\n\n") // possible bad cached access token, retry at = nil continue @@ -197,11 +198,11 @@ func (w *WebSSOAuthentication) EstablishIAMCredentials() error { if len(apps) == 0 { errMsg := ` There aren't any AWS Federation Applications associated with OIDC App %q. -Check if it has %q scope and is the allowed web SSO client for an AWS +Check if it has %q scopes and is the allowed web SSO client for an AWS Federation app. Or, invoke okta-aws-cli including the client ID of the AWS Federation App with --aws-acct-fed-app-id FED_APP_ID ` - return fmt.Errorf(errMsg, clientID, "okta.apps.read") + return fmt.Errorf(errMsg, clientID, "okta.apps.read or okta.users.read.self") } var fedAppID string @@ -285,7 +286,7 @@ func (w *WebSSOAuthentication) selectFedApp(apps []*okta.Application) (string, e idpARN = app.Settings.App.IdentityProviderARN } - if idpARN == app.Settings.App.IdentityProviderARN { + if app.Settings.App.IdentityProviderARN != "" && idpARN == app.Settings.App.IdentityProviderARN { if !w.config.IsProcessCredentialsFormat() { idpData := idpTemplateData{ IDP: choiceLabel, @@ -802,10 +803,10 @@ func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization } } -// ListFedApp Lists Okta AWS Fed Apps that are active. Errors after that occur -// after getting anything other than a 403 on /api/v1/apps will be wrapped as as -// an error that is related having multiple fed apps available. Requires -// assoicated OIDC app has been granted okta.apps.read to its scope. +// listFedApps Lists Okta AWS Fed Apps that are active. Errors after that occur +// after getting anything other than a 403 on /api/v1/apps will be retried on +// the fall back to /api/v1/users/me/appLinks . Requires assoicated OIDC app has +// been granted okta.apps.read to its scope. func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken) ([]*okta.Application, error) { apiURL, err := url.Parse(fmt.Sprintf("https://%s/api/v1/apps", w.config.OrgDomain())) if err != nil { @@ -827,7 +828,9 @@ func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken allApps := make([]*okta.Application, 0) resp, err := pgntr.GetItems(&allApps) - // TODO fall back to /api/v1/users/{userId}/appLinks if 403 / http.StatusForbidden + if resp.StatusCode == http.StatusForbidden { + return w.listFedAppsFromAppLinks(clientID, &headers) + } if err != nil { return nil, err } @@ -860,6 +863,55 @@ func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken return apps, nil } +// listFedAppsFromAppLinks Lists Okta AWS Fed Apps assign to the current user +// via appLinks Requires assoicated OIDC app has been granted +// okta.users.read.self to its scope. +func (w *WebSSOAuthentication) listFedAppsFromAppLinks(clientID string, headers *map[string]string) ([]*okta.Application, error) { + apiURL, err := url.Parse(fmt.Sprintf("https://%s/api/v1/users/me/appLinks", w.config.OrgDomain())) + if err != nil { + return nil, err + } + + params := map[string]string{ + // NOTE: leaving in limit 200 but it doesn't appear that /api/v1/users/me/appLinks makes use of the limit parameter + "limit": "200", + "q": amazonAWS, + "filter": `status eq "ACTIVE"`, + } + pgntr := paginator.NewPaginator(w.config.HTTPClient(), apiURL, headers, ¶ms) + + allApps := make([]*okta.ApplicationLink, 0) + resp, err := pgntr.GetItems(&allApps) + if err != nil { + return nil, err + } + + for resp.HasNextPage() { + var nextApps []*okta.ApplicationLink + resp, err = resp.Next(&nextApps) + if err != nil { + return nil, err + } + allApps = append(allApps, nextApps...) + } + + apps := make([]*okta.Application, 0) + for _, appLink := range allApps { + // even though the query was for AWS fed apps check just in case + if appLink.Name != amazonAWS { + continue + } + app := okta.Application{ + ID: appLink.ID, + Name: appLink.Name, + Label: appLink.Label, + } + apps = append(apps, &app) + } + + return apps, nil +} + // accessToken see: // https://developer.okta.com/docs/reference/api/oidc/#token func (w *WebSSOAuthentication) accessToken(deviceAuth *okta.DeviceAuthorization) (at *okta.AccessToken, err error) { @@ -935,7 +987,7 @@ func (w *WebSSOAuthentication) authorize() (*okta.DeviceAuthorization, error) { apiURL := fmt.Sprintf("https://%s/oauth2/v1/device/authorize", w.config.OrgDomain()) data := url.Values{ "client_id": {clientID}, - "scope": {"openid okta.apps.sso okta.apps.read"}, + "scope": {"openid okta.apps.sso okta.apps.read okta.users.read.self"}, } body := strings.NewReader(data.Encode()) req, err := http.NewRequest(http.MethodPost, apiURL, body) From 423723b235b6e7d75ea21af44539b3e75c2aa056 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 3 Jul 2024 10:51:26 -0700 Subject: [PATCH 5/9] Code clean up and TODO about DRY-ing up listFedAppsFromAppLinks implementation. --- internal/webssoauth/webssoauth.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 1f7998d..0d55723 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -361,9 +361,6 @@ func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID str } } else { ccch := w.fetchAllAWSCredentialsWithSAMLRole(idpRolesMap, assertion, region) - if err != nil { - return err - } for cc := range ccch { err = output.RenderAWSCredential(w.config, cc) if err != nil { @@ -728,9 +725,6 @@ func (w *WebSSOAuthentication) fetchSSOWebToken(clientID, awsFedAppID string, at if resp.StatusCode != http.StatusOK { baseErrStr := "fetching SSO web token received API response %q" - if err != nil { - return nil, fmt.Errorf(baseErrStr, resp.Status) - } var apiErr okta.APIError err = json.NewDecoder(resp.Body).Decode(&apiErr) @@ -829,7 +823,14 @@ func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken allApps := make([]*okta.Application, 0) resp, err := pgntr.GetItems(&allApps) if resp.StatusCode == http.StatusForbidden { - return w.listFedAppsFromAppLinks(clientID, &headers) + // fall back to using app links + return w.listFedAppsFromAppLinks(&headers) + + // TODO: might be worth spending some time DRY-ing up with an an + // okta.App interface that okta.ApplicationLink and okta.Application + // implement so we can get rid of the extra listFedAppsFromAppLinks + // method. If the first call to GetItems fails make a new paginator that + // uses the /api/v1/users/me/appLinks endpoint. } if err != nil { return nil, err @@ -866,7 +867,7 @@ func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken // listFedAppsFromAppLinks Lists Okta AWS Fed Apps assign to the current user // via appLinks Requires assoicated OIDC app has been granted // okta.users.read.self to its scope. -func (w *WebSSOAuthentication) listFedAppsFromAppLinks(clientID string, headers *map[string]string) ([]*okta.Application, error) { +func (w *WebSSOAuthentication) listFedAppsFromAppLinks(headers *map[string]string) ([]*okta.Application, error) { apiURL, err := url.Parse(fmt.Sprintf("https://%s/api/v1/users/me/appLinks", w.config.OrgDomain())) if err != nil { return nil, err From a6b07bc41089667b31bd074e063b04c7160844f4 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 2 Jul 2024 13:44:07 -0700 Subject: [PATCH 6/9] Update goreleaser https://goreleaser.com/deprecations/#-rm-dist --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c36011c..900a122 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,10 +31,10 @@ jobs: passphrase: ${{ secrets.PASSPHRASE }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v3.0.0 + uses: goreleaser/goreleaser-action@v6.0.0 with: version: latest - args: release --rm-dist + args: release --clean env: GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} # GitHub sets this automatically From 36766c6718540cb8e0bc1bfc6f1bc023d50e74dc Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 2 Jul 2024 13:41:25 -0700 Subject: [PATCH 7/9] Starting changelog entry --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ae1d1..4b2030d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2.X.X (MM d, 2024) + +### ENHANCEMENTS + +* Seamless support for non-Admin users if OIDC app has `okta.users.read.self` grant [#TDB](https://github.com/okta/okta-aws-cli/pull/TBD), thanks [@monde](https://github.com/monde)! +* Improve README with note about device state in policy [#205](https://github.com/okta/okta-aws-cli/pull/205), thanks [@ramgandhi-okta](https://github.com/ramgandhi-okta)! +* Correct m2m typo in README [#201](https://github.com/okta/okta-aws-cli/pull/201), thanks [@stefan-lsx](https://github.com/stefan-lsx)! + +### BUG FIXES + +* Paginating more than 200 apps on `GET /api/v1/apps` not implemented [#212](https://github.com/okta/okta-aws-cli/pull/212), thanks [@pmgalea](https://github.com/pmgalea)! +* Respect `OKTA_AWSCLI_AWS_REGION` env var value when saving to the profile [#203](https://github.com/okta/okta-aws-cli/pull/203), thanks [@sudolibre](https://github.com/sudolibre)! +* Default profile value not correctly set to `default` [#200](https://github.com/okta/okta-aws-cli/pull/200), thanks [@mantoine96](https://github.com/mantoine96)! + ## 2.1.2 (February 27, 2024) ### BUG FIXES From 261edd872f2ffeb5db8d9e0fdda06bb2116d2815 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 3 Jul 2024 10:56:41 -0700 Subject: [PATCH 8/9] Set up 2.2.0 release --- CHANGELOG.md | 4 ++-- internal/config/config.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b2030d..d8e4661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## 2.X.X (MM d, 2024) +## 2.2.0 (July 3, 2024) ### ENHANCEMENTS -* Seamless support for non-Admin users if OIDC app has `okta.users.read.self` grant [#TDB](https://github.com/okta/okta-aws-cli/pull/TBD), thanks [@monde](https://github.com/monde)! +* Seamless support for non-Admin users if OIDC app has `okta.users.read.self` grant [#213](https://github.com/okta/okta-aws-cli/pull/213), thanks [@monde](https://github.com/monde)! * Improve README with note about device state in policy [#205](https://github.com/okta/okta-aws-cli/pull/205), thanks [@ramgandhi-okta](https://github.com/ramgandhi-okta)! * Correct m2m typo in README [#201](https://github.com/okta/okta-aws-cli/pull/201), thanks [@stefan-lsx](https://github.com/stefan-lsx)! diff --git a/internal/config/config.go b/internal/config/config.go index f8f4622..395cb9e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,7 +40,7 @@ func init() { const ( // Version app version - Version = "2.1.2" + Version = "2.2.0" //////////////////////////////////////////////////////////// // FORMATS From 83bcb0240876b778231f720ec059131c21750d0b Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 3 Jul 2024 11:19:42 -0700 Subject: [PATCH 9/9] `make qc` --- go.sum | 2 ++ internal/paginator/paginator.go | 33 ++++++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/go.sum b/go.sum index e1940e8..d1b846c 100644 --- a/go.sum +++ b/go.sum @@ -439,6 +439,8 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/paginator/paginator.go b/internal/paginator/paginator.go index f2e4a72..c80d858 100644 --- a/internal/paginator/paginator.go +++ b/internal/paginator/paginator.go @@ -14,6 +14,18 @@ import ( "github.com/BurntSushi/toml" ) +const ( + // HTTPHeaderWwwAuthenticate Www-Authenticate header + HTTPHeaderWwwAuthenticate = "Www-Authenticate" + // APIErrorMessageBase base API error message + APIErrorMessageBase = "the API returned an unknown error" + // APIErrorMessageWithErrorDescription API error message with description + APIErrorMessageWithErrorDescription = "the API returned an error: %s" + // APIErrorMessageWithErrorSummary API error message with summary + APIErrorMessageWithErrorSummary = "the API returned an error: %s" +) + +// PaginateResponse HTTP Response wrapper for behavior the the Paginator type PaginateResponse struct { *http.Response pgntr *Paginator @@ -21,10 +33,12 @@ type PaginateResponse struct { NextPage string } +// HasNextPage Paginate response has a next page func (r *PaginateResponse) HasNextPage() bool { return r.NextPage != "" } +// Next Paginate response to call for next page func (r *PaginateResponse) Next(v interface{}) (*PaginateResponse, error) { req, err := http.NewRequest(http.MethodGet, r.NextPage, nil) for k, v := range *r.pgntr.headers { @@ -36,6 +50,7 @@ func (r *PaginateResponse) Next(v interface{}) (*PaginateResponse, error) { return r.pgntr.Do(req, v) } +// Paginator Paginates Okta's API response Link(s) type Paginator struct { httpClient *http.Client url *url.URL @@ -43,6 +58,7 @@ type Paginator struct { params *map[string]string } +// NewPaginator Paginator constructor func NewPaginator(httpClient *http.Client, url *url.URL, headers *map[string]string, params *map[string]string) *Paginator { pgntr := Paginator{ httpClient: httpClient, @@ -53,6 +69,7 @@ func NewPaginator(httpClient *http.Client, url *url.URL, headers *map[string]str return &pgntr } +// Do Paginator does an HTTP request func (pgntr *Paginator) Do(req *http.Request, v interface{}) (*PaginateResponse, error) { resp, err := pgntr.httpClient.Do(req) if err != nil { @@ -61,6 +78,7 @@ func (pgntr *Paginator) Do(req *http.Request, v interface{}) (*PaginateResponse, return buildPaginateResponse(resp, pgntr, &v) } +// GetItems Paginator gets an array of items of type v func (pgntr *Paginator) GetItems(v interface{}) (resp *PaginateResponse, err error) { params := url.Values{} if pgntr.params != nil { @@ -88,7 +106,6 @@ func newPaginateResponse(r *http.Response, pgntr *Paginator) *PaginateResponse { if len(links) == 0 { return response - } for _, link := range links { splitLinkHeader := strings.Split(link, ";") @@ -158,8 +175,8 @@ func checkResponseForError(resp *http.Response) error { } e := Error{} if (statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden) && - strings.Contains(resp.Header.Get("Www-Authenticate"), "Bearer") { - for _, v := range strings.Split(resp.Header.Get("Www-Authenticate"), ", ") { + strings.Contains(resp.Header.Get(HTTPHeaderWwwAuthenticate), "Bearer") { + for _, v := range strings.Split(resp.Header.Get(HTTPHeaderWwwAuthenticate), ", ") { if strings.Contains(v, "error_description") { _, err := toml.Decode(v, &e) if err != nil { @@ -181,22 +198,24 @@ func checkResponseForError(resp *http.Response) error { return &e } +// Error A struct for marshalling Okta's API error response bodies type Error struct { ErrorMessage string `json:"error"` ErrorDescription string `json:"error_description"` ErrorCode string `json:"errorCode,omitempty"` ErrorSummary string `json:"errorSummary,omitempty" toml:"error_description"` ErrorLink string `json:"errorLink,omitempty"` - ErrorId string `json:"errorId,omitempty"` + ErrorID string `json:"errorId,omitempty"` ErrorCauses []map[string]interface{} `json:"errorCauses,omitempty"` } +// Error String-ify the Error func (e *Error) Error() string { - formattedErr := "the API returned an unknown error" + formattedErr := APIErrorMessageBase if e.ErrorDescription != "" { - formattedErr = fmt.Sprintf("the API returned an error: %s", e.ErrorDescription) + formattedErr = fmt.Sprintf(APIErrorMessageWithErrorDescription, e.ErrorDescription) } else if e.ErrorSummary != "" { - formattedErr = fmt.Sprintf("the API returned an error: %s", e.ErrorSummary) + formattedErr = fmt.Sprintf(APIErrorMessageWithErrorSummary, e.ErrorSummary) } if len(e.ErrorCauses) > 0 { var causes []string