├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── aws.go ├── go.mod ├── go.sum ├── main.go └── saml.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | vendor 14 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | release: 2 | github: 3 | owner: knqyf263 4 | name: azaws 5 | name_template: '{{.Tag}}' 6 | builds: 7 | - goos: 8 | - linux 9 | - darwin 10 | goarch: 11 | - amd64 12 | - "386" 13 | goarm: 14 | - "6" 15 | main: . 16 | ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} 17 | archive: 18 | format: zip 19 | name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 20 | files: 21 | - LICENSE* 22 | - README* 23 | - CHANGELOG* 24 | nfpm: 25 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 26 | replacements: 27 | amd64: 64-bit 28 | 386: 32-bit 29 | darwin: macOS 30 | linux: Tux 31 | vendor: knqyf263 32 | homepage: https://github.com/knqyf263/azaws 33 | maintainer: Teppei Fukuda 34 | license: MIT 35 | formats: 36 | - deb 37 | - rpm 38 | checksum: 39 | name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt' 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | sudo: false 4 | addons: 5 | apt: 6 | packages: 7 | - ruby 8 | - ruby-dev 9 | - build-essential 10 | - rpm 11 | 12 | go: 13 | - "1.10" 14 | script: 15 | - make dep 16 | # calls goreleaser 17 | deploy: 18 | - provider: script 19 | skip_cleanup: true 20 | script: curl -sL https://git.io/goreleaser | bash 21 | on: 22 | tags: true 23 | condition: $TRAVIS_OS_NAME = linux 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Teppei Fukuda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dep build 2 | 3 | VERSION := $(shell git describe --tags) 4 | LDFLAGS := '-s -w -X main.version=$(VERSION)' 5 | 6 | build: main.go 7 | go build -ldflags $(LDFLAGS) -o $@ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/knqyf263/azaws.svg?branch=master)](https://travis-ci.org/knqyf263/azaws) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/knqyf263/azaws)](https://goreportcard.com/report/github.com/knqyf263/azaws) 3 | [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/knqyf263/azaws/blob/master/LICENSE) 4 | 5 | # azaws 6 | If your organization uses [Azure Active Directory](https://azure.microsoft.com) to provide SSO login to the AWS console, then there is no easy way to log in on the command line or to use the [AWS CLI](https://aws.amazon.com/cli/). This tool fixes that. It lets you use the normal Azure AD login (including MFA) from a command line to create a federated AWS session and places the temporary credentials in the proper place for the AWS CLI and SDKs. 7 | 8 | Inspired by [aws-azure-login](https://github.com/dtjohnson/aws-azure-login) 9 | 10 | ## Install 11 | 12 | ### From source 13 | ``` 14 | $ go install github.com/knqyf263/azaws@latest 15 | ``` 16 | 17 | ### RedHat, CentOS 18 | ``` 19 | $ sudo rpm -ivh https://github.com/knqyf263/azaws/releases/download/v0.0.1/azaws_0.0.1_Tux_64-bit.rpm 20 | ``` 21 | 22 | ### Debian, Ubuntu 23 | ``` 24 | $ wget https://github.com/knqyf263/azaws/releases/download/v0.0.1/azaws_0.0.1_Tux_64-bit.deb 25 | $ dpkg -i azaws_0.0.1_linux_amd64.deb 26 | ``` 27 | 28 | ### Other 29 | Download binary from https://github.com/knqyf263/azaws/releases 30 | 31 | ## Usage 32 | ### Configuration 33 | 34 | ``` 35 | $ azaws --configure 36 | Azure Tenant ID: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX 37 | Azure App ID URI: https://signin.aws.amazon.com/saml?XXXXXXXXXXXX 38 | ``` 39 | 40 | ### Log in 41 | The following command will open Google Chrome. 42 | 43 | ``` 44 | $ azaws --role [YOUR ROLE NAME] 45 | ``` 46 | 47 | Enter your credentials and log in to Azure. 48 | 49 | After that, you can use aws-cli. 50 | ``` 51 | $ aws sts get-caller-identity --profile [YOUR_PROFILE_NAME (default: azaws)] 52 | ``` 53 | 54 | ### Option 55 | 56 | ``` 57 | Usage of azaws: 58 | -configure 59 | Configure options 60 | -profile string 61 | AWS profile name (default "azaws") 62 | -role string 63 | AWS role name (required) 64 | -user-data-dir string 65 | Chrome option (default "/tmp/azaws") 66 | ``` 67 | 68 | ## Getting Your Tenant ID and App ID URI 69 | 70 | Your Azure AD system admin should be able to provide you with your Tenant ID and App ID URI. If you can't get it from them, you can scrape it from a login page from the myapps.microsoft.com page. 71 | 72 | 1. Load the myapps.microsoft.com page. 73 | 2. Click the chicklet for the login you want. 74 | 3. In the window the pops open quickly copy the login.microsoftonline.com URL. (If you miss it just try again. You can also open the developer console with nagivation preservation to capture the URL.) 75 | 4. The GUID right after login.microsoftonline.com/ is the tenant ID. 76 | 5. Copy the SAMLRequest URL param. 77 | 6. Paste it into a URL decoder ([like this one](https://www.samltool.com/url.php)) and decode. 78 | 7. Paste the decoded output into the a SAML deflated and encoded XML decoder ([like this one](https://www.samltool.com/decode.php)). 79 | 8. In the decoded XML output the value of the Issuer tag is the App ID URI. 80 | 81 | ## Author 82 | 83 | Teppei Fukuda 84 | -------------------------------------------------------------------------------- /aws.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/sts" 11 | "github.com/go-ini/ini" 12 | "github.com/mitchellh/go-homedir" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | func assumeRoleWithSAML(ctx context.Context, roleArn, principalArn, assertion string, durationHours int) (*sts.Credentials, error) { 17 | sess := session.Must(session.NewSession()) 18 | svc := sts.New(sess) 19 | 20 | input := &sts.AssumeRoleWithSAMLInput{ 21 | DurationSeconds: aws.Int64(int64(durationHours) * 60 * 60), 22 | RoleArn: aws.String(roleArn), 23 | PrincipalArn: aws.String(principalArn), 24 | SAMLAssertion: aws.String(assertion), 25 | } 26 | res, err := svc.AssumeRoleWithSAMLWithContext(ctx, input) 27 | if err != nil { 28 | return nil, errors.Wrap(err, "Failed to AssumeRoleWithSAMLWithContext") 29 | } 30 | return res.Credentials, nil 31 | } 32 | 33 | func getProfileConfig(profileName string) (tenantID, appID string, durationHours int, err error) { 34 | configFile, err := getAWSConfigFilePath("config") 35 | if err != nil { 36 | return "", "", -1, errors.Wrapf(err, "Failed to get AWS config file path") 37 | } 38 | cfg, err := ini.Load(configFile) 39 | if err != nil { 40 | return "", "", -1, errors.Wrapf(err, "Failed to read file: %s", configFile) 41 | } 42 | if profileName != "default" { 43 | profileName = fmt.Sprintf("profile %s", profileName) 44 | } 45 | tenantID = cfg.Section(profileName).Key("azure_tenant_id").MustString("") 46 | appID = cfg.Section(profileName).Key("azure_app_id").MustString("") 47 | durationHours = cfg.Section(profileName).Key("azure_duration_hours").MustInt(1) 48 | return tenantID, appID, durationHours, nil 49 | } 50 | 51 | func setProfileConfig(profileName string, tenantID, appID string, durationHours int) error { 52 | configFile, err := getAWSConfigFilePath("config") 53 | if err != nil { 54 | return errors.Wrapf(err, "Failed to get AWS config file path") 55 | } 56 | cfg, err := ini.Load(configFile) 57 | if err != nil { 58 | return errors.Wrapf(err, "Failed to read file: %s", configFile) 59 | } 60 | if profileName != "default" { 61 | profileName = fmt.Sprintf("profile %s", profileName) 62 | } 63 | cfg.Section(profileName).Key("azure_tenant_id").SetValue(tenantID) 64 | cfg.Section(profileName).Key("azure_app_id").SetValue(appID) 65 | cfg.Section(profileName).Key("azure_duration_hours").SetValue(fmt.Sprint(durationHours)) 66 | return cfg.SaveTo(configFile) 67 | } 68 | 69 | func setProfileCredentials(profileName string, credentials *sts.Credentials) error { 70 | credentialFile, err := getAWSConfigFilePath("credentials") 71 | if err != nil { 72 | return errors.Wrapf(err, "Failed to get AWS config file path") 73 | } 74 | cfg, err := ini.Load(credentialFile) 75 | if err != nil { 76 | return errors.Wrapf(err, "Failed to read file: %s", credentialFile) 77 | } 78 | cfg.Section(profileName).Key("aws_access_key_id").SetValue(*credentials.AccessKeyId) 79 | cfg.Section(profileName).Key("aws_secret_access_key").SetValue(*credentials.SecretAccessKey) 80 | cfg.Section(profileName).Key("aws_session_token").SetValue(*credentials.SessionToken) 81 | cfg.Section(profileName).Key("aws_session_expiration").SetValue(credentials.Expiration.String()) 82 | return cfg.SaveTo(credentialFile) 83 | } 84 | 85 | func getAWSConfigFilePath(fileName string) (string, error) { 86 | homeDir, err := homedir.Dir() 87 | if err != nil { 88 | return "", errors.Wrap(err, "Failed to get home directory") 89 | } 90 | filePath := filepath.Join(homeDir, ".aws", fileName) 91 | return filePath, nil 92 | } 93 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/knqyf263/azaws 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | github.com/RobotsAndPencils/go-saml v0.0.0-20170520135329-fb13cb52a46b 7 | github.com/aws/aws-sdk-go v1.25.15 8 | github.com/chromedp/cdproto v0.0.0-20240721024200-dac8efcb39ce 9 | github.com/chromedp/chromedp v0.9.5 10 | github.com/go-ini/ini v1.49.0 11 | github.com/google/uuid v1.1.1 12 | github.com/mitchellh/go-homedir v1.1.0 13 | github.com/pkg/errors v0.8.1 14 | ) 15 | 16 | require ( 17 | github.com/chromedp/sysutil v1.0.0 // indirect 18 | github.com/gobwas/httphead v0.1.0 // indirect 19 | github.com/gobwas/pool v0.2.1 // indirect 20 | github.com/gobwas/ws v1.4.0 // indirect 21 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af // indirect 22 | github.com/josharian/intern v1.0.0 // indirect 23 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect 24 | github.com/mailru/easyjson v0.7.7 // indirect 25 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 26 | github.com/smartystreets/goconvey v1.8.1 // indirect 27 | github.com/stretchr/testify v1.8.4 // indirect 28 | golang.org/x/sys v0.22.0 // indirect 29 | gopkg.in/ini.v1 v1.67.0 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/RobotsAndPencils/go-saml v0.0.0-20170520135329-fb13cb52a46b h1:EgJ6N2S0h1WfFIjU5/VVHWbMSVYXAluop97Qxpr/lfQ= 2 | github.com/RobotsAndPencils/go-saml v0.0.0-20170520135329-fb13cb52a46b/go.mod h1:3SAoF0F5EbcOuBD5WT9nYkbIJieBS84cUQXADbXeBsU= 3 | github.com/aws/aws-sdk-go v1.25.15 h1:2XrAm3F7QuNguJXJ6SUJxywL1TPNDM3z/mNEaS0CHtk= 4 | github.com/aws/aws-sdk-go v1.25.15/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 5 | github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= 6 | github.com/chromedp/cdproto v0.0.0-20240721024200-dac8efcb39ce h1:pvzUsAunw3R7swXkLT6vqv81Awhnds43mbZHAzhn2pQ= 7 | github.com/chromedp/cdproto v0.0.0-20240721024200-dac8efcb39ce/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= 8 | github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg= 9 | github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= 10 | github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= 11 | github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/go-ini/ini v1.49.0 h1:ymWFBUkwN3JFPjvjcJJ5TSTwh84M66QrH+8vOytLgRY= 15 | github.com/go-ini/ini v1.49.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 16 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 17 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 18 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 19 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 20 | github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= 21 | github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 22 | github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 23 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 24 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 25 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 26 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 27 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= 28 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 29 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 30 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 31 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 32 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 33 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= 34 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= 35 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= 36 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 37 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 38 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 39 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 40 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 41 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 42 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 43 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= 44 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 45 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 46 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= 50 | github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= 51 | github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 52 | github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 53 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 54 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 55 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 57 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 58 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 59 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 60 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 61 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 62 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/base64" 7 | "encoding/json" 8 | "flag" 9 | "fmt" 10 | "log" 11 | "net/url" 12 | "os" 13 | "os/signal" 14 | "strconv" 15 | "syscall" 16 | 17 | "github.com/chromedp/cdproto" 18 | "github.com/chromedp/cdproto/network" 19 | "github.com/chromedp/chromedp" 20 | "github.com/pkg/errors" 21 | ) 22 | 23 | var ( 24 | configureMode bool 25 | profileName string 26 | roleName string 27 | userDataDir string 28 | 29 | msgChann = make(chan cdproto.Message) 30 | ) 31 | 32 | func devToolHandler(s string, is ...interface{}) { 33 | go func() { 34 | for _, elem := range is { 35 | var msg cdproto.Message 36 | // The CDP messages are sent as strings so we need to convert them back 37 | json.Unmarshal([]byte(fmt.Sprintf("%s", elem)), &msg) 38 | msgChann <- msg 39 | } 40 | }() 41 | } 42 | 43 | func handleSignal(cancel context.CancelFunc) { 44 | signalChannel := make(chan os.Signal, 1) 45 | signal.Notify(signalChannel, 46 | syscall.SIGHUP, 47 | syscall.SIGINT, 48 | syscall.SIGTERM, 49 | syscall.SIGQUIT) 50 | 51 | go func() { 52 | for { 53 | s := <-signalChannel 54 | switch s { 55 | case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT: 56 | cancel() 57 | } 58 | } 59 | }() 60 | } 61 | 62 | func usage() { 63 | fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) 64 | flag.PrintDefaults() 65 | os.Exit(1) 66 | } 67 | 68 | func main() { 69 | if err := run(); err != nil { 70 | log.Fatal(err) 71 | } 72 | } 73 | 74 | func run() error { 75 | flag.BoolVar(&configureMode, "configure", false, "Configure options") 76 | flag.StringVar(&profileName, "profile", "azaws", "AWS profile name") 77 | flag.StringVar(&roleName, "role", "", "AWS role name (required)") 78 | flag.StringVar(&userDataDir, "user-data-dir", "/tmp/azaws", "Chrome option") 79 | flag.Parse() 80 | 81 | if configureMode { 82 | return configure() 83 | } 84 | 85 | tenantID, appID, durationHours, err := getProfileConfig(profileName) 86 | if err != nil { 87 | return errors.Wrap(err, "Failed to get config parameters") 88 | } 89 | if tenantID == "" || appID == "" { 90 | return errors.New("You must configure it first with --configure") 91 | } 92 | 93 | if roleName == "" { 94 | os.Stderr.WriteString("the 'role' option is required\n") 95 | usage() 96 | } 97 | 98 | ctx, cancel := context.WithCancel(context.Background()) 99 | defer cancel() 100 | 101 | handleSignal(cancel) 102 | 103 | // create chrome instance 104 | ctx, cancel = chromedp.NewExecAllocator(ctx, chromedp.Flag("user-data-dir", userDataDir), chromedp.Flag("disable-infobars", true)) 105 | defer cancel() 106 | 107 | ctx, cancel = chromedp.NewContext(ctx, chromedp.WithDebugf(devToolHandler)) 108 | defer cancel() 109 | 110 | err = chromedp.Run(ctx, network.Enable()) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | samlRequest, err := createSAMLRequest(appID) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | loginURL := `https://login.microsoftonline.com/%s/saml2?SAMLRequest=%s` 121 | loginURL = fmt.Sprintf(loginURL, tenantID, url.QueryEscape(samlRequest)) 122 | 123 | err = chromedp.Run(ctx, chromedp.Navigate(loginURL)) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | err = chromedp.Run(ctx, chromedp.ActionFunc(func(_ context.Context) error { 129 | for { 130 | var msg cdproto.Message 131 | select { 132 | case <-ctx.Done(): 133 | return ctx.Err() 134 | case msg = <-msgChann: 135 | } 136 | switch msg.Method.String() { 137 | case "Network.requestWillBeSent": 138 | var reqWillSend network.EventRequestWillBeSent 139 | if err = json.Unmarshal(msg.Params, &reqWillSend); err != nil { 140 | return err 141 | } 142 | if reqWillSend.Request.URL != "https://signin.aws.amazon.com/saml" || !reqWillSend.Request.HasPostData || 143 | len(reqWillSend.Request.PostDataEntries) == 0 { 144 | continue 145 | } 146 | dec, err := base64.StdEncoding.DecodeString(reqWillSend.Request.PostDataEntries[0].Bytes) 147 | if err != nil { 148 | return errors.Wrap(err, "Failed to decode post data") 149 | } 150 | form, err := url.ParseQuery(string(dec)) 151 | if err != nil { 152 | return errors.Wrap(err, "Failed to parse query") 153 | } 154 | samlResponse, ok := form["SAMLResponse"] 155 | if !ok || len(samlResponse) == 0 { 156 | return errors.Wrap(err, "No such key: SAMLResponse") 157 | } 158 | err = assumeRole(ctx, samlResponse[0], durationHours) 159 | if err != nil { 160 | return errors.Wrap(err, "Failed to assume role") 161 | } 162 | return nil 163 | } 164 | } 165 | })) 166 | if err != nil { 167 | return errors.Wrap(err, "Failed to handle events") 168 | } 169 | return nil 170 | } 171 | 172 | func assumeRole(ctx context.Context, assertion string, durationHours int) error { 173 | roleArn, principalArn, err := parseArn(assertion) 174 | if err != nil { 175 | return errors.Wrap(err, "Failed to parse arn from SAML response") 176 | } 177 | 178 | credentials, err := assumeRoleWithSAML(ctx, roleArn, principalArn, assertion, durationHours) 179 | if err != nil { 180 | return errors.Wrap(err, "Failed to assume role with SAML") 181 | } 182 | return setProfileCredentials(profileName, credentials) 183 | } 184 | 185 | func configure() error { 186 | tenantID, err := prompt("Azure Tenant ID: ") 187 | if err != nil { 188 | return errors.Wrap(err, "Failed to get Tenant ID") 189 | } 190 | appID, err := prompt("Azure App ID URI: ") 191 | if err != nil { 192 | return errors.Wrap(err, "Failed to get Azure App ID URI") 193 | } 194 | durationHours, err := promptInt("Session Duration Hours (up to 12): ") 195 | if err != nil { 196 | return errors.Wrap(err, "Failed to get Azure App ID URI") 197 | } 198 | return setProfileConfig(profileName, tenantID, appID, durationHours) 199 | } 200 | 201 | func prompt(q string) (string, error) { 202 | fmt.Print(q) 203 | scanner := bufio.NewScanner(os.Stdin) 204 | scanner.Scan() 205 | answer := scanner.Text() 206 | if err := scanner.Err(); err != nil { 207 | return "", errors.Wrap(err, "Failed to parse user input") 208 | } 209 | return answer, nil 210 | } 211 | 212 | func promptInt(q string) (num int, err error) { 213 | scanner := bufio.NewScanner(os.Stdin) 214 | fmt.Print(q) 215 | for scanner.Scan() { 216 | answer := scanner.Text() 217 | num, err = strconv.Atoi(answer) 218 | if err != nil { 219 | fmt.Println("Enter number") 220 | fmt.Print(q) 221 | continue 222 | } 223 | 224 | if err := scanner.Err(); err != nil { 225 | return -1, errors.Wrap(err, "Failed to parse user input") 226 | } 227 | break 228 | } 229 | return num, nil 230 | } 231 | -------------------------------------------------------------------------------- /saml.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "encoding/base64" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | saml "github.com/RobotsAndPencils/go-saml" 12 | "github.com/google/uuid" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | func createSAMLRequest(appID string) (request string, err error) { 17 | uuid, err := uuid.NewRandom() 18 | if err != nil { 19 | return "", errors.Wrap(err, "Failed to generate uuid") 20 | } 21 | request = ` 22 | %s 23 | 24 | ` 25 | request = fmt.Sprintf(request, uuid.String(), time.Now().Format(time.RFC3339), appID) 26 | 27 | // Deflate 28 | var b bytes.Buffer 29 | w, err := flate.NewWriter(&b, 9) 30 | if err != nil { 31 | return "", errors.Wrap(err, "Failed to get a new Writer compressing data") 32 | } 33 | if _, err := w.Write([]byte(request)); err != nil { 34 | return "", errors.Wrap(err, "Failed to deflate SAML request") 35 | } 36 | w.Close() 37 | 38 | // Base64 Encode 39 | encodedSAMLRequest := base64.StdEncoding.EncodeToString(b.Bytes()) 40 | return encodedSAMLRequest, nil 41 | } 42 | 43 | func parseArn(assertion string) (roleArn, principalArn string, err error) { 44 | response, err := saml.ParseEncodedResponse(assertion) 45 | if err != nil { 46 | return "", "", errors.Wrap(err, "Failed to parse encoded SAML response") 47 | } 48 | 49 | for _, attribute := range response.Assertion.AttributeStatement.Attributes { 50 | if attribute.Name != "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" && attribute.Name != "https://aws.amazon.com/SAML/Attributes/Role" { 51 | continue 52 | } 53 | for _, v := range attribute.AttributeValues { 54 | s := strings.Split(v.Value, ",") 55 | roleArn = s[0] 56 | principalArn = s[1] 57 | if strings.HasSuffix(roleArn, roleName) { 58 | break 59 | } 60 | } 61 | break 62 | } 63 | if roleArn == "" || principalArn == "" { 64 | return "", "", errors.Wrapf(err, "The specified role could not be found in SAML response: %s", roleName) 65 | } 66 | return roleArn, principalArn, nil 67 | } 68 | --------------------------------------------------------------------------------