Versent / saml2aws

CLI tool which enables you to login and retrieve AWS temporary credentials using a SAML IDP
https://github.com/Versent/saml2aws
MIT License
2.08k stars 562 forks source link

saml2aws fails to login to newly revamped login page #1110

Closed vchepkov closed 10 months ago

vchepkov commented 1 year ago

Attempting to login to AWS now fail with the following error:

Failed to assume role. Please check whether you are permitted to assume the given role for the AWS service.: No accounts available.

pcasaes commented 1 year ago

Seeing the same here.

djtecha commented 1 year ago

Starting today it appears that the azure login fails to return roles. This was working a few days ago with 0 changes to the saml2aws version, IAM roles, or Azure AD. Not sure if others are experiencing this caused by some change from AWS or Azure?


DEBU[0014] processing SAMLRequest                        provider=AzureAD
DEBU[0014] processing a SAMLResponse                     provider=AzureAD
No accounts available.
github.com/versent/saml2aws/v2/cmd/saml2aws/commands.resolveRole
        github.com/versent/saml2aws/v2/cmd/saml2aws/commands/login.go:302
github.com/versent/saml2aws/v2/cmd/saml2aws/commands.selectAwsRole
        github.com/versent/saml2aws/v2/cmd/saml2aws/commands/login.go:272
github.com/versent/saml2aws/v2/cmd/saml2aws/commands.Login
        github.com/versent/saml2aws/v2/cmd/saml2aws/commands/login.go:131
main.main
        ./main.go:191
runtime.main
        runtime/proc.go:250
runtime.goexit
        runtime/asm_amd64.s:1598
Failed to assume role. Please check whether you are permitted to assume the given role for the AWS service.
github.com/versent/saml2aws/v2/cmd/saml2aws/commands.Login
        github.com/versent/saml2aws/v2/cmd/saml2aws/commands/login.go:133
main.main
        ./main.go:191
runtime.main
        runtime/proc.go:250
runtime.goexit
        runtime/asm_amd64.s:1598
ReagentX commented 1 year ago

Seems to come from:

https://github.com/Versent/saml2aws/blob/ca63a28969851e5de7c4159face407cfeecae7db/aws_account.go#L35-L57

leftfieldhero commented 1 year ago

The https://signin.aws.amazon.com/saml UI has changed and now there are dashes on the account numbers and there weren't before and the page is drastically different. Looks like saml2aws scrapes this screen from code above found by @ReagentX . And there's no fieldset on the page any longer.

MichaelPalmer1 commented 1 year ago

The role data can be pulled from the <meta name="data" content="[base64 encoded json object containing role information]">.

This makes it a lot cleaner actually.

When decoded, this is the essential piece:

{
   "roles_accounts": {
      "account-name (111111111111)": [
        "arn:aws:iam::111111111111:role/role-123",
        "arn:aws:iam::111111111111:role/role-456"
      ]
   }
}
mstump commented 1 year ago

Is there a workaround until there is a patch?

XSchelin commented 1 year ago

A dev at our place linked to this as a temporary workaround: https://blog.knoldus.com/how-to-make-assumerolewithsaml-calls-with-aws-cli/amp/

jaredallard commented 1 year ago

The role data can be pulled from the <meta name="data" content="[base64 encoded json object containing role information]">.

This makes it a lot cleaner actually.

Here's the full payload, if it's helpful:

{
  "invalid_accounts": {},
  "RelayState": null,
  "name": null,
  "roles_accounts": {
    "accountName (accountId)": [
      "arn:aws:iam::NUMBER:role/friendly_name",
    ]
  },
  "foreign_accounts": {},
  "region": "REGION",
  "portal": null,
  "SAMLResponse": "<reallyLongString>",
  "problems": "{}",
  "policy": null
}
jaredallard commented 1 year ago

Here's a quick patch of something that seems to be working locally:

diff --git a/aws_account.go b/aws_account.go
index ff28c3a..3cca122 100644
--- a/aws_account.go
+++ b/aws_account.go
@@ -2,10 +2,13 @@ package saml2aws

 import (
    "bytes"
+   "encoding/base64"
+   "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
+   "strings"

    "github.com/PuerkitoBio/goquery"
    "github.com/pkg/errors"
@@ -41,17 +44,53 @@ func ExtractAWSAccounts(data []byte) ([]*AWSAccount, error) {
        return nil, errors.Wrap(err, "failed to build document from response")
    }

-   doc.Find("fieldset > div.saml-account").Each(func(i int, s *goquery.Selection) {
-       account := new(AWSAccount)
-       account.Name = s.Find("div.saml-account-name").Text()
-       s.Find("label").Each(func(i int, s *goquery.Selection) {
-           role := new(AWSRole)
-           role.Name = s.Text()
-           role.RoleARN, _ = s.Attr("for")
-           account.Roles = append(account.Roles, role)
-       })
-       accounts = append(accounts, account)
-   })
+   b64data, ok := doc.Find("meta[name=data]").Attr("content")
+   if !ok {
+       return nil, errors.New("failed to find meta[name=data] in AWS response")
+   }
+
+   // decode the base64 encoded data
+   data, err = base64.StdEncoding.DecodeString(b64data)
+   if err != nil {
+       return nil, errors.Wrap(err, "failed to decode base64 data")
+   }
+
+   type dataResponse struct {
+       InvalidAccounts struct{}            `json:"invalid_accounts"`
+       RelayState      any                 `json:"RelayState"`
+       Name            any                 `json:"name"`
+       RolesAccounts   map[string][]string `json:"roles_accounts"`
+       ForeignAccounts struct{}            `json:"foreign_accounts"`
+       Region          string              `json:"region"`
+       Portal          any                 `json:"portal"`
+       Problems        string              `json:"problems"`
+       Policy          any                 `json:"policy"`
+   }
+
+   dr := &dataResponse{}
+   if err := json.Unmarshal(data, dr); err != nil {
+       return nil, errors.Wrap(err, "failed to unmarshal data")
+   }
+
+   // for each account map to our structure
+   for account, roles := range dr.RolesAccounts {
+       name := strings.TrimSpace(strings.Split(account, "(")[0])
+
+       awsAccount := &AWSAccount{
+           Name: name,
+       }
+
+       for _, role := range roles {
+           awsRole := &AWSRole{
+               Name:    role,
+               RoleARN: role,
+           }
+
+           awsAccount.Roles = append(awsAccount.Roles, awsRole)
+       }
+
+       accounts = append(accounts, awsAccount)
+   }

    return accounts, nil
 }

I make no guarantees for how well it works for all edgecases :)

Huge thanks to @MichaelPalmer1 for discovering the meta tag which made this a lot easier.

jaredallard commented 1 year ago

If anyone wants to carry forward ^ into a PR and update the testdata, please feel free to do so. I'm about to go on an international flight, so it might take me a bit if I do it :P Consider the code to be MIT-licensed just like the repository is.

mKeRix commented 1 year ago

Another quick workaround, in case you don't want to mess with the saml2aws code or the assume role API calls directly:

  1. Open the AWS console in your browser via whatever login method you use for the account you want to access.
  2. Open CloudShell. Screen Shot 2023-08-21 at 19 47 01
  3. Type aws configure export-credentials --format env.
  4. Copy the resulting output into your shell.
ReagentX commented 1 year ago

It appears the login page update was rolled back on the AWS side.

jaredallard commented 1 year ago

It appears the login page update was rolled back on the AWS side.

I was a little worried about that. It makes supporting it a little more painful since we won't be able to get a good test without the live page... 😢 (jokes on me for not saving it to disk to formulate a testdata entry)

We probably want to support both methods in a PR in case it switches back and forth again, too.

djtecha commented 1 year ago

Great rally though!

andrea-l-conway commented 1 year ago

Here is a sample of the HTML for the role table:

<ul class="saml-form_custom_list__LuP_V">
    <li class="saml-form_custom_list_item__H1wPE">
    <div class="flex flex-row">
        <div class="flex-grow">
        <div class="saml-form_account_name_div__dDGkX">aws-account-1 </div>
        <div class="saml-form_account_id_div__Q1PBA">1234-5678-9012</div>
        <div class="saml-form_account_role_div__FhQmy">
            <div class="saml-form_role_div__BeMu2">
            <a class="saml-form_role_text__Cfxwn" id="arn:aws:iam::123456789012:role/AWS-IAM-Role-Name" data-testid="roleLink-arn:aws:iam::123456789012:role" role="button" aria-label="Account ID 1234-5678-9012 Role Name AWS-IAM-Role-Name" tabindex="0">AWS-IAM-Role-Name</a>
            <p class="saml-form_space__uJWq3"></p>
            </div>
            <div class="saml-form_role_div__BeMu2">
            <a class="saml-form_role_text__Cfxwn" id="arn:aws:iam::123456789012:role/AWS-IAM-Role-Name" data-testid="roleLink-arn:aws:iam::123456789012:role" role="button" aria-label="Account ID 1234-5678-9012 Role Name AWS-IAM-Role-Name" tabindex="0">AWS-IAM-Role-Name</a>
            <p class="saml-form_space__uJWq3"></p>
            </div>
            <div class="saml-form_role_div__BeMu2">
            <a class="saml-form_role_text__Cfxwn" id="arn:aws:iam::123456789012:role/AWS-IAM-Role-Name" data-testid="roleLink-arn:aws:iam::123456789012:role" role="button" aria-label="Account ID 1234-5678-9012 Role Name AWS-IAM-Role-Name" tabindex="0">AWS-IAM-Role-Name</a>
            <p class="saml-form_space__uJWq3"></p>
            </div>
            <div class="saml-form_role_div__BeMu2">
            <a class="saml-form_role_text__Cfxwn" id="arn:aws:iam::123456789012:role/AWS-IAM-Role-Name" data-testid="roleLink-arn:aws:iam::123456789012:role" role="button" aria-label="Account ID 1234-5678-9012 Role Name AWS-IAM-Role-Name" tabindex="0">AWS-IAM-Role-Name</a>
            </div>
        </div>
        </div>
    </div>
    </li>
    <li class="saml-form_custom_list_item__H1wPE">
    <div class="flex flex-row">
        <div class="flex-grow">
        <div class="saml-form_account_name_div__dDGkX">aws-account-2 </div>
        <div class="saml-form_account_id_div__Q1PBA">1234-5678-9012</div>
        <div class="saml-form_account_role_div__FhQmy">
            <div class="saml-form_role_div__BeMu2">
            <a class="saml-form_role_text__Cfxwn" id="arn:aws:iam::123456789012:role/AWS-IAM-Role-Name" data-testid="roleLink-arn:aws:iam::123456789012:role" role="button" aria-label="Account ID 1234-5678-9012 Role Name AWS-IAM-Role-Name" tabindex="0">AWS-IAM-Role-Name</a>
            <p class="saml-form_space__uJWq3"></p>
            </div>
            <div class="saml-form_role_div__BeMu2">
            <a class="saml-form_role_text__Cfxwn" id="arn:aws:iam::123456789012:role/AWS-IAM-Role-Name" data-testid="roleLink-arn:aws:iam::123456789012:role" role="button" aria-label="Account ID 1234-5678-9012 Role Name AWS-IAM-Role-Name" tabindex="0">AWS-IAM-Role-Name</a>
            <p class="saml-form_space__uJWq3"></p>
            </div>
            <div class="saml-form_role_div__BeMu2">
            <a class="saml-form_role_text__Cfxwn" id="arn:aws:iam::123456789012:role/AWS-IAM-Role-Name" data-testid="roleLink-arn:aws:iam::123456789012:role" role="button" aria-label="Account ID 1234-5678-9012 Role Name AWS-IAM-Role-Name" tabindex="0">AWS-IAM-Role-Name</a>
            <p class="saml-form_space__uJWq3"></p>
            </div>
            <div class="saml-form_role_div__BeMu2">
            <a class="saml-form_role_text__Cfxwn" id="arn:aws:iam::123456789012:role/AWS-IAM-Role-Name" data-testid="roleLink-arn:aws:iam::123456789012:role" role="button" aria-label="Account ID 1234-5678-9012 Role Name AWS-IAM-Role-Name" tabindex="0">AWS-IAM-Role-Name</a>
            </div>
        </div>
        </div>
    </div>
    </li>
</ul>

And here is a sample of the JSON blob in <meta name="data" content="[base64 blob]"> after base64 decoding:

{
  "invalid_accounts": {},
  "RelayState": "",
  "name": null,
  "roles_accounts": {
    "aws-account-1 (123456789012)": [
      "arn:aws:iam::123456789012:role/AWS-IAM-Role-Name",
      "arn:aws:iam::123456789012:role/AWS-IAM-Role-Name",
      "arn:aws:iam::123456789012:role/AWS-IAM-Role-Name",
      "arn:aws:iam::123456789012:role/AWS-IAM-Role-Name"
    ],
    "aws-account-2 (123456789012)": [
      "arn:aws:iam::123456789012:role/AWS-IAM-Role-Name",
      "arn:aws:iam::123456789012:role/AWS-IAM-Role-Name",
      "arn:aws:iam::123456789012:role/AWS-IAM-Role-Name",
      "arn:aws:iam::123456789012:role/AWS-IAM-Role-Name"
    ]
  },
  "foreign_accounts": {},
  "region": "IAD",
  "portal": null,
  "SAMLResponse": "SGVsbG8sIFdvcmxkIQ==",
  "problems": "{}",
  "policy": null
}
rr-jianan-lei commented 1 year ago

Here's a quick patch of something that seems to be working locally:

diff --git a/aws_account.go b/aws_account.go
index ff28c3a..3cca122 100644
--- a/aws_account.go
+++ b/aws_account.go
@@ -2,10 +2,13 @@ package saml2aws

 import (
  "bytes"
+ "encoding/base64"
+ "encoding/json"
  "fmt"
  "io"
  "net/http"
  "net/url"
+ "strings"

  "github.com/PuerkitoBio/goquery"
  "github.com/pkg/errors"
@@ -41,17 +44,53 @@ func ExtractAWSAccounts(data []byte) ([]*AWSAccount, error) {
      return nil, errors.Wrap(err, "failed to build document from response")
  }

- doc.Find("fieldset > div.saml-account").Each(func(i int, s *goquery.Selection) {
-     account := new(AWSAccount)
-     account.Name = s.Find("div.saml-account-name").Text()
-     s.Find("label").Each(func(i int, s *goquery.Selection) {
-         role := new(AWSRole)
-         role.Name = s.Text()
-         role.RoleARN, _ = s.Attr("for")
-         account.Roles = append(account.Roles, role)
-     })
-     accounts = append(accounts, account)
- })
+ b64data, ok := doc.Find("meta[name=data]").Attr("content")
+ if !ok {
+     return nil, errors.New("failed to find meta[name=data] in AWS response")
+ }
+
+ // decode the base64 encoded data
+ data, err = base64.StdEncoding.DecodeString(b64data)
+ if err != nil {
+     return nil, errors.Wrap(err, "failed to decode base64 data")
+ }
+
+ type dataResponse struct {
+     InvalidAccounts struct{}            `json:"invalid_accounts"`
+     RelayState      any                 `json:"RelayState"`
+     Name            any                 `json:"name"`
+     RolesAccounts   map[string][]string `json:"roles_accounts"`
+     ForeignAccounts struct{}            `json:"foreign_accounts"`
+     Region          string              `json:"region"`
+     Portal          any                 `json:"portal"`
+     Problems        string              `json:"problems"`
+     Policy          any                 `json:"policy"`
+ }
+
+ dr := &dataResponse{}
+ if err := json.Unmarshal(data, dr); err != nil {
+     return nil, errors.Wrap(err, "failed to unmarshal data")
+ }
+
+ // for each account map to our structure
+ for account, roles := range dr.RolesAccounts {
+     name := strings.TrimSpace(strings.Split(account, "(")[0])
+
+     awsAccount := &AWSAccount{
+         Name: name,
+     }
+
+     for _, role := range roles {
+         awsRole := &AWSRole{
+             Name:    role,
+             RoleARN: role,
+         }
+
+         awsAccount.Roles = append(awsAccount.Roles, awsRole)
+     }
+
+     accounts = append(accounts, awsAccount)
+ }

  return accounts, nil
 }

I make no guarantees for how well it works for all edgecases :)

Huge thanks to @MichaelPalmer1 for discovering the meta tag which made this a lot easier.

Based on the RCA from AWS, they mentioned Logins to other Regions were not affected and existing authentication sessions were not impacted., so the change only impacted US-EAST-1. I would suggest to keep the old saml-account div tag parser, and add the new logic to better support all regions.

joepurdy commented 1 year ago

I'm the author of the PR to implement a fix for gimme-aws-creds and I wanted to drop a note here that if it helps folks working to patch saml2aws, I did dump the new NextJS sign-in page before AWS rolled back earlier today. I have that saved as a sanitized mock file for use as a fixture for the tests for gimme-aws-creds. You can review that file at https://github.com/Nike-Inc/gimme-aws-creds/blob/master/tests/fixtures/aws_nextjs.html

Hope that helps author a patch for saml2aws 🫡

rr-jianan-lei commented 1 year ago

Can we parse the saml assertion to get the roles not depending on the frontend? Something like:

import (
    "encoding/xml"
    "github.com/crewjam/saml"
)

roles := []string{}
var response saml.Response
err := xml.Unmarshal(samlAssertion, &response)
if err != nil {
    return []string{}, errors.Wrap(err, "error unmarshal saml assertion")
}

for _, attributeStatement := range response.Assertion.AttributeStatements {
    for _, attribute := range attributeStatement.Attributes {
        if attribute.Name == "https://aws.amazon.com/SAML/Attributes/Role" {
        for _, attributeValue := range attribute.Values {
        roles = append(roles, attributeValue.Value)
        }
    }
    }
}

The AWS account names can still be fetched from frontend. I think for AWS, they also depending on the saml assertion to form the frontend, and directly parse saml assertion can reduce the impact of changes from AWS side. Just a thought.

tinaboyce commented 10 months ago

@vchepkov (and anyone who is interested in this issue), for house-keeping, are you still experiencing this issue?

vchepkov commented 10 months ago

I am not, but only because AWS rolled back the changes and never re-applied them again

tinaboyce commented 10 months ago

Thanks for the follow up @vchepkov and thanks everyone for this input. Let's hope it was just a mistake that won't regress.

To everyone, I'll close it for now.