├── .github ├── actions │ └── setup-go │ │ └── action.yml └── workflows │ ├── ci.yml │ ├── dry-run-renovate.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .tool-versions ├── LICENSE ├── Makefile ├── README.md ├── aws ├── console.go ├── credentials.go ├── saml.go └── saml_test.go ├── cmd └── root.go ├── config └── config.go ├── defaults └── defaults.go ├── go.mod ├── go.sum ├── idp └── azure.go ├── main.go ├── prompt ├── prompt.go └── prompt_test.go └── renovate.json5 /.github/actions/setup-go/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Go 2 | 3 | description: Sets up Go with the version listed in .tool-versions 4 | 5 | runs: 6 | using: composite 7 | 8 | steps: 9 | - id: tool-versions 10 | run: awk '{ print $1"="$2 }' .tool-versions >> $GITHUB_OUTPUT 11 | shell: bash 12 | 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version: ${{ steps.tool-versions.outputs.golang }} 16 | cache: true 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags-ignore: 7 | - '**' 8 | pull_request: 9 | 10 | jobs: 11 | static-analysis: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: ./.github/actions/setup-go 16 | - id: tool-versions 17 | run: awk '{ print $1"="$2 }' .tool-versions >> $GITHUB_OUTPUT 18 | shell: bash 19 | - uses: golangci/golangci-lint-action@a4f60bb28d35aeee14e6880718e0c85ff1882e64 # v6.0.1 20 | with: 21 | version: v${{ steps.tool-versions.outputs.golangci-lint }} 22 | go-mod-tidy: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: ./.github/actions/setup-go 27 | - name: go mod tidy 28 | run: | 29 | go mod tidy 30 | git diff --exit-code 31 | test: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: ./.github/actions/setup-go 36 | - name: Install gotestsum 37 | run: go install gotest.tools/gotestsum@latest 38 | - name: Test 39 | run: make test 40 | validate-renovate-config: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Validate Renovate config 45 | run: npx --package renovate renovate-config-validator 46 | package: 47 | needs: [static-analysis, go-mod-tidy, test] 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: ./.github/actions/setup-go 52 | - id: tool-versions 53 | run: awk '{ print $1"="$2 }' .tool-versions >> $GITHUB_OUTPUT 54 | shell: bash 55 | - uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0 56 | with: 57 | version: "v${{ steps.tool-versions.outputs.goreleaser }}" 58 | install-only: true 59 | - name: Package binaries 60 | run: make package 61 | - uses: actions/upload-artifact@v4 62 | with: 63 | name: dist 64 | path: dist 65 | -------------------------------------------------------------------------------- /.github/workflows/dry-run-renovate.yml: -------------------------------------------------------------------------------- 1 | name: Dry-run Renovate 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths: 7 | - renovate.json5 8 | - .github/workflows/dry-run-renovate.yml 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | renovate-dry-run: 15 | permissions: 16 | contents: read 17 | pull-requests: read 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: cybozu/renovate-dry-run-action@v2 22 | with: 23 | config-file: renovate.json5 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+' 6 | - 'v[0-9]+.[0-9]+.[0-9]+-*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - uses: ./.github/actions/setup-go 16 | - id: tool-versions 17 | run: awk '{ print $1"="$2 }' .tool-versions >> $GITHUB_OUTPUT 18 | shell: bash 19 | - uses: actions/create-github-app-token@v1 20 | id: app-token 21 | with: 22 | app-id: ${{ secrets.APP_ID }} 23 | private-key: ${{ secrets.PRIVATE_KEY }} 24 | repositories: "assam,homebrew-assam" 25 | - uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0 26 | with: 27 | version: "v${{ steps.tool-versions.outputs.goreleaser }}" 28 | args: release --clean 29 | env: 30 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 31 | -------------------------------------------------------------------------------- /.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 | 14 | # Test report 15 | test-results/ 16 | 17 | # Release packages 18 | dist/ 19 | 20 | # execution file 21 | assam 22 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # golangci-lint configuration 2 | # https://github.com/golangci/golangci-lint 3 | # Default values are https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml 4 | 5 | linters: 6 | enable: 7 | - revive 8 | - gofmt 9 | - govet 10 | fast: true 11 | 12 | issues: 13 | # Independently from option `exclude` we use default exclude patterns, 14 | # it can be disabled by this option. To list all 15 | # excluded by default patterns execute `golangci-lint run --help`. 16 | # Default value for this option is true. 17 | exclude-use-default: false 18 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Make sure to check the documentation at http://goreleaser.com 2 | before: 3 | hooks: 4 | - go mod download 5 | builds: 6 | - id: assam 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - windows 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm64 16 | ldflags: 17 | - "-s -w -X \"github.com/cybozu/assam/cmd.version={{.Version}}\" -X \"github.com/cybozu/assam/cmd.commit={{.ShortCommit}}\" -X \"github.com/cybozu/assam/cmd.date={{.Date}}\"" 18 | archives: 19 | - name_template: >- 20 | {{ .ProjectName }}_{{ .Version }}_{{- title .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end }} 21 | format_overrides: 22 | - goos: windows 23 | format: zip 24 | checksum: 25 | name_template: 'checksums.txt' 26 | snapshot: 27 | name_template: "{{ .Tag }}-next" 28 | changelog: 29 | sort: asc 30 | filters: 31 | exclude: 32 | - '^docs:' 33 | - '^test:' 34 | - '^refactor:' 35 | - '^ci:' 36 | - '^chore:' 37 | - '^style:' 38 | - Merge pull request 39 | - Merge branch 40 | brews: 41 | - tap: 42 | owner: cybozu 43 | name: homebrew-assam 44 | description: "Get a credential by AssumeRoleWithSAML for AWS CLI and SDK" 45 | # Skip Homebrew Formula upload when prerelease. 46 | skip_upload: auto 47 | release: 48 | prerelease: auto 49 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | goreleaser 1.16.1 2 | golang 1.22.4 3 | golangci-lint 1.59.1 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Cybozu 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 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help 4 | help: 5 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ 6 | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 7 | 8 | .PHONY: lint 9 | lint: ## lint: Analyze code for potential errors. 10 | golangci-lint run 11 | 12 | .PHONY: test 13 | test: ## test: Test packages. 14 | mkdir -p test-results 15 | gotestsum --junitfile test-results/results.xml 16 | 17 | .PHONY: package 18 | package: ## package: build executable binary archives. 19 | goreleaser --snapshot --skip-publish --clean 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | assam 2 | ==== 3 | 4 | > [!WARNING] 5 | > assam was deprecated on 31 July 2024. Installation of assam by Homebrew will be disabled on 31 July 2025. 6 | > As an alternative, you can use the official [AWS IAM Identity Center](https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html) or fork assam and maintain it yourself. 7 | 8 | It is difficult to get a credential of AWS when using AssumeRoleWithSAML. This tool simplifies it. 9 | 10 | ## Requirement 11 | 12 | The following operating systems are supported: 13 | 14 | - Windows 15 | - macOS 16 | - Linux 17 | 18 | And Google Chrome is required. 19 | 20 | ## Usage 21 | 22 | ``` 23 | Usage: assam [options] 24 | 25 | options: 26 | -c, --configure 27 | Configuration Mode 28 | -p, --profile string 29 | AWS profile name (default: "default") 30 | -w, --web 31 | Open the AWS Console URL in your default browser (*1) 32 | ``` 33 | 34 | Please be careful that assam overrides default profile in `.aws/credentials` by default. 35 | If you don't want that, please specify `-p|--profile` option. 36 | 37 | ## Install 38 | 39 | ### Homebrew 40 | 41 | ```bash 42 | $ brew install cybozu/assam/assam 43 | ``` 44 | 45 | ### Manual 46 | 47 | Download a binary file from [Release](https://github.com/cybozu/assam/releases) and save it to the desired location. 48 | 49 | ## Notes 50 | 51 | ### (*1) Command to open the default browser 52 | 53 | - Windows: `start` 54 | - macOS : `open` 55 | - Linux: `xdg-open` 56 | 57 | ## Contribution 58 | 59 | 1. Fork ([https://github.com/cybozu/assam](https://github.com/cybozu/assam)) 60 | 2. Create a feature branch 61 | 3. Commit your changes 62 | 4. Rebase your local changes against the master branch 63 | 5. Create new Pull Request 64 | 6. Green CI Tests 65 | 66 | ## Licence 67 | 68 | [MIT](https://github.com/cybozu/assam/blob/master/LICENSE) 69 | -------------------------------------------------------------------------------- /aws/console.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | "github.com/aws/aws-sdk-go/aws/credentials" 15 | "github.com/aws/aws-sdk-go/aws/session" 16 | ) 17 | 18 | // AWSClient is an interface for AWS operations 19 | type awsClientInterface interface { 20 | GetConsoleURL() (string, error) 21 | } 22 | 23 | // awsClient is the implementation of AWSClient interface 24 | type awsClient struct { 25 | session *session.Session 26 | } 27 | 28 | // NewAWSClient creates a new AWSClient instance 29 | // 30 | // By default NewSession will only load credentials from the shared credentials file (~/.aws/credentials). 31 | func NewAWSClient() awsClientInterface { 32 | // Create session 33 | sess := session.Must(session.NewSessionWithOptions(session.Options{ 34 | SharedConfigState: session.SharedConfigEnable, 35 | })) 36 | 37 | return &awsClient{ 38 | session: sess, 39 | } 40 | } 41 | 42 | // GetConsoleURL returns the AWS Management Console URL 43 | // ref: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html 44 | func (c *awsClient) GetConsoleURL() (string, error) { 45 | amazonDomain := c.getConsoleDomain(*c.session.Config.Region) 46 | 47 | // Create get signin token URL 48 | creds, err := c.session.Config.Credentials.Get() 49 | if err != nil { 50 | return "", errors.New("failed to get aws credential: please authenticate with `assam`") 51 | } 52 | 53 | token, err := c.getSigninToken(creds, amazonDomain) 54 | if err != nil { 55 | return "", err 56 | } 57 | 58 | targetURL := fmt.Sprintf("https://console.%s/console/home", amazonDomain) 59 | params := url.Values{ 60 | "Action": []string{"login"}, 61 | "Destination": []string{targetURL}, 62 | "SigninToken": []string{token}, 63 | } 64 | 65 | return fmt.Sprintf("https://signin.%s/federation?%s", amazonDomain, params.Encode()), nil 66 | } 67 | 68 | // getConsoleDomain returns the console domain based on the region 69 | func (c *awsClient) getConsoleDomain(region string) string { 70 | var amazonDomain string 71 | 72 | if strings.HasPrefix(region, "us-gov-") { 73 | amazonDomain = "amazonaws-us-gov.com" 74 | } else if strings.HasPrefix(region, "cn-") { 75 | amazonDomain = "amazonaws.cn" 76 | } else { 77 | amazonDomain = "aws.amazon.com" 78 | } 79 | return amazonDomain 80 | } 81 | 82 | // getSinginToken retrieves the signin token 83 | func (c *awsClient) getSigninToken(creds credentials.Value, amazonDomain string) (string, error) { 84 | urlCreds := map[string]string{ 85 | "sessionId": creds.AccessKeyID, 86 | "sessionKey": creds.SecretAccessKey, 87 | "sessionToken": creds.SessionToken, 88 | } 89 | 90 | bytes, err := json.Marshal(urlCreds) 91 | if err != nil { 92 | return "", err 93 | } 94 | 95 | params := url.Values{ 96 | "Action": []string{"getSigninToken"}, 97 | "DurationSeconds": []string{"900"}, // DurationSeconds minimum value 98 | "Session": []string{string(bytes)}, 99 | } 100 | tokenRequest := fmt.Sprintf("https://signin.%s/federation?%s", amazonDomain, params.Encode()) 101 | 102 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 103 | defer cancel() 104 | 105 | // Construct a request to the federation URL. 106 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenRequest, nil) 107 | if err != nil { 108 | return "", err 109 | } 110 | 111 | resp, err := http.DefaultClient.Do(req) 112 | if err != nil { 113 | return "", err 114 | } 115 | defer resp.Body.Close() 116 | 117 | if resp.StatusCode != http.StatusOK { 118 | return "", fmt.Errorf("request failed: %s", resp.Status) 119 | } 120 | 121 | // Extract a signin token from the response body. 122 | token, err := c.getToken(resp.Body) 123 | if err != nil { 124 | return "", err 125 | } 126 | 127 | return token, nil 128 | } 129 | 130 | // getToken extracts the signin token from the response body 131 | func (c *awsClient) getToken(reader io.Reader) (string, error) { 132 | type response struct { 133 | SigninToken string 134 | } 135 | 136 | var resp response 137 | if err := json.NewDecoder(reader).Decode(&resp); err != nil { 138 | return "", err 139 | } 140 | 141 | return resp.SigninToken, nil 142 | } 143 | -------------------------------------------------------------------------------- /aws/credentials.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws/defaults" 5 | "github.com/aws/aws-sdk-go/service/sts" 6 | "gopkg.in/ini.v1" 7 | "os" 8 | ) 9 | 10 | // SaveCredentials saves credentials to AWS credentials file. 11 | func SaveCredentials(profileName string, credentials sts.Credentials) error { 12 | file := getCredentialsFilename() 13 | c, err := ini.LooseLoad(file) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | s := c.Section(profileName) 19 | s.Key("aws_access_key_id").SetValue(*credentials.AccessKeyId) 20 | s.Key("aws_secret_access_key").SetValue(*credentials.SecretAccessKey) 21 | s.Key("aws_session_token").SetValue(*credentials.SessionToken) 22 | s.Key("aws_session_expiration").SetValue(credentials.Expiration.String()) 23 | 24 | return c.SaveTo(file) 25 | } 26 | 27 | func getCredentialsFilename() string { 28 | // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html 29 | file := os.Getenv("AWS_SHARED_CREDENTIALS_FILE") 30 | if len(file) == 0 { 31 | file = defaults.SharedCredentialsFilename() 32 | } 33 | return file 34 | } 35 | -------------------------------------------------------------------------------- /aws/saml.go: -------------------------------------------------------------------------------- 1 | // Package aws provides the functionality about AWS. 2 | package aws 3 | 4 | import ( 5 | "bytes" 6 | "compress/flate" 7 | "context" 8 | "encoding/base64" 9 | "encoding/xml" 10 | "fmt" 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/session" 13 | "github.com/aws/aws-sdk-go/service/sts" 14 | "github.com/google/uuid" 15 | "strings" 16 | "time" 17 | ) 18 | 19 | const ( 20 | // EndpointURL receives SAML response. 21 | EndpointURL = "https://signin.aws.amazon.com/saml" 22 | 23 | roleAttributeName = "https://aws.amazon.com/SAML/Attributes/Role" 24 | ) 25 | 26 | // SAMLResponse is SAML response 27 | type SAMLResponse struct { 28 | Assertion Assertion 29 | } 30 | 31 | // Assertion is an Assertion element of SAML response 32 | type Assertion struct { 33 | AttributeStatement AttributeStatement 34 | } 35 | 36 | // AttributeStatement is an AttributeStatement element of SAML response 37 | type AttributeStatement struct { 38 | Attributes []Attribute `xml:"Attribute"` 39 | } 40 | 41 | // Attribute is an Attribute element of SAML response 42 | type Attribute struct { 43 | Name string `xml:",attr"` 44 | AttributeValues []AttributeValue `xml:"AttributeValue"` 45 | } 46 | 47 | // AttributeValue is an AttributeValue element of SAML response 48 | type AttributeValue struct { 49 | Value string `xml:",innerxml"` 50 | } 51 | 52 | // CreateSAMLRequest creates the Base64 encoded SAML authentication request XML compressed by Deflate. 53 | func CreateSAMLRequest(appIDURI string) (string, error) { 54 | // https://docs.microsoft.com/en-us/azure/active-directory/develop/single-sign-on-saml-protocol 55 | // ID must not begin with a number, so a common strategy is to prepend a string like "id" to the string 56 | // representation of a GUID. 57 | // See https://www.w3.org/TR/xmlschema-2/#ID 58 | xml := ` 59 | 66 | %s 67 | 68 | 69 | ` 70 | 71 | id, err := uuid.NewRandom() 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | instant := time.Now().Format(time.RFC3339) 77 | request := fmt.Sprintf(xml, EndpointURL, id, instant, appIDURI) 78 | 79 | deflated, err := deflate(request) 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | encoded := base64.StdEncoding.EncodeToString(deflated.Bytes()) 85 | 86 | return encoded, nil 87 | } 88 | 89 | // ParseSAMLResponse parses base64 encoded response to SAMLResponse structure 90 | func ParseSAMLResponse(base64Response string) (*SAMLResponse, error) { 91 | responseData, err := base64.StdEncoding.DecodeString(base64Response) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | response := SAMLResponse{} 97 | err = xml.Unmarshal(responseData, &response) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return &response, nil 103 | } 104 | 105 | // ExtractRoleArnAndPrincipalArn extracts role ARN and principal ARN from SAML response 106 | func ExtractRoleArnAndPrincipalArn(samlResponse SAMLResponse, roleName string) (string, string, error) { 107 | for _, attr := range samlResponse.Assertion.AttributeStatement.Attributes { 108 | if attr.Name != roleAttributeName { 109 | continue 110 | } 111 | 112 | for _, v := range attr.AttributeValues { 113 | s := strings.Split(v.Value, ",") 114 | roleArn := s[0] 115 | principalArn := s[1] 116 | if roleName != "" && strings.Split(roleArn, "/")[1] != roleName { 117 | continue 118 | } 119 | return roleArn, principalArn, nil 120 | } 121 | } 122 | 123 | return "", "", fmt.Errorf("no such attribute: %s", roleAttributeName) 124 | } 125 | 126 | // AssumeRoleWithSAML sends a AssumeRoleWithSAML request to AWS and returns credentials 127 | func AssumeRoleWithSAML(ctx context.Context, durationHours int, roleArn string, principalArn string, base64Response string) (*sts.Credentials, error) { 128 | sess := session.Must(session.NewSession()) 129 | svc := sts.New(sess) 130 | 131 | input := sts.AssumeRoleWithSAMLInput{ 132 | DurationSeconds: aws.Int64(int64(durationHours) * 60 * 60), 133 | RoleArn: aws.String(roleArn), 134 | PrincipalArn: aws.String(principalArn), 135 | SAMLAssertion: aws.String(base64Response), 136 | } 137 | res, err := svc.AssumeRoleWithSAMLWithContext(ctx, &input) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | return res.Credentials, nil 143 | } 144 | 145 | func deflate(src string) (*bytes.Buffer, error) { 146 | b := new(bytes.Buffer) 147 | 148 | w, err := flate.NewWriter(b, 9) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | if _, err := w.Write([]byte(src)); err != nil { 154 | return nil, err 155 | } 156 | 157 | err = w.Close() 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | return b, nil 163 | } 164 | -------------------------------------------------------------------------------- /aws/saml_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "encoding/base64" 7 | "encoding/xml" 8 | "io/ioutil" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type SAMLRequest struct { 15 | XMLName xml.Name `xml:"AuthnRequest"` 16 | XMLNamespace string `xml:"xmlns samlp,attr"` 17 | AssertionConsumerServiceURL string `xml:"AssertionConsumerServiceURL,attr"` 18 | ID string `xml:"ID,attr"` 19 | IssueInstant string `xml:"IssueInstant,attr"` 20 | ProtocolBinding string `xml:"ProtocolBinding,attr"` 21 | Version string `xml:"Version,attr"` 22 | Issuer Issuer `xml:"Issuer"` 23 | NameIDPolicy NameIDPolicy `xml:"NameIDPolicy"` 24 | } 25 | 26 | type Issuer struct { 27 | XMLName xml.Name `xml:"Issuer"` 28 | XMLNamespace string `xml:"xmlns saml,attr"` 29 | AppIDURI string `xml:",chardata"` 30 | } 31 | 32 | type NameIDPolicy struct { 33 | XMLName xml.Name `xml:"NameIDPolicy"` 34 | Format string `xml:"Format,attr"` 35 | } 36 | 37 | func TestCreateSAMLRequest(t *testing.T) { 38 | t.Run("Should have App ID URI at Issuer element", func(t *testing.T) { 39 | // setup 40 | appIDURI := "https://signin.aws.amazon.com/saml#sample" 41 | 42 | // exercise 43 | got, err := CreateSAMLRequest(appIDURI) 44 | if err != nil { 45 | t.Errorf("CreateSAMLRequest() error = %v", err) 46 | return 47 | } 48 | 49 | // verify 50 | b, err := base64.StdEncoding.DecodeString(got) 51 | if err != nil { 52 | t.Error(err) 53 | return 54 | } 55 | 56 | r := flate.NewReader(bytes.NewReader(b)) 57 | xmlData, err := ioutil.ReadAll(r) 58 | if err != nil { 59 | t.Error(err) 60 | return 61 | } 62 | 63 | request := SAMLRequest{} 64 | err = xml.Unmarshal(xmlData, &request) 65 | if err != nil { 66 | t.Error(err) 67 | return 68 | } 69 | 70 | assert.Equal(t, "https://signin.aws.amazon.com/saml", request.AssertionConsumerServiceURL) 71 | assert.Equal(t, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", request.ProtocolBinding) 72 | assert.Equal(t, "2.0", request.Version) 73 | assert.Equal(t, "urn:oasis:names:tc:SAML:2.0:protocol", request.XMLNamespace) 74 | assert.Equal(t, "urn:oasis:names:tc:SAML:2.0:assertion", request.Issuer.XMLNamespace) 75 | assert.Equal(t, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", request.NameIDPolicy.Format) 76 | 77 | assert.NotEmpty(t, request.ID) 78 | assert.NotEmpty(t, request.IssueInstant) 79 | assert.Equal(t, appIDURI, request.Issuer.AppIDURI) 80 | }) 81 | } 82 | 83 | func TestParseSAMLResponse(t *testing.T) { 84 | t.Run("Parse SAML response", func(t *testing.T) { 85 | // setup 86 | response := ` 87 | 88 | http://idp.example.com/metadata.php 89 | 90 | 91 | 92 | 93 | http://idp.example.com/metadata.php 94 | 95 | _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 96 | 97 | 98 | 99 | 100 | 101 | 102 | http://sp.example.com/demo1/metadata.php 103 | 104 | 105 | 106 | 107 | urn:oasis:names:tc:SAML:2.0:ac:classes:Password 108 | 109 | 110 | 111 | 112 | test 113 | 114 | 115 | test@example.com 116 | 117 | 118 | 119 | 120 | ` 121 | base64Response := base64.StdEncoding.EncodeToString([]byte(response)) 122 | 123 | // exercise 124 | got, err := ParseSAMLResponse(base64Response) 125 | if err != nil { 126 | t.Errorf("ParseSAMLResponse() error = %v", err) 127 | return 128 | } 129 | 130 | // verify 131 | assert.Equal(t, "uid", got.Assertion.AttributeStatement.Attributes[0].Name) 132 | assert.Equal(t, "test", got.Assertion.AttributeStatement.Attributes[0].AttributeValues[0].Value) 133 | assert.Equal(t, "mail", got.Assertion.AttributeStatement.Attributes[1].Name) 134 | assert.Equal(t, "test@example.com", got.Assertion.AttributeStatement.Attributes[1].AttributeValues[0].Value) 135 | }) 136 | } 137 | 138 | func TestExtractRoleArnAndPrincipalArn(t *testing.T) { 139 | type args struct { 140 | samlResponse SAMLResponse 141 | roleName string 142 | } 143 | tests := []struct { 144 | name string 145 | args args 146 | wantRoleArn string 147 | wantPrincipalArn string 148 | wantErr bool 149 | }{ 150 | { 151 | name: "extracts role ARN and principal ARN", 152 | args: args{ 153 | samlResponse: SAMLResponse{ 154 | Assertion: Assertion{ 155 | AttributeStatement: AttributeStatement{ 156 | Attributes: []Attribute{ 157 | { 158 | Name: "dummy", 159 | AttributeValues: []AttributeValue{ 160 | { 161 | Value: "dummy", 162 | }, 163 | }, 164 | }, 165 | { 166 | Name: roleAttributeName, 167 | AttributeValues: []AttributeValue{ 168 | { 169 | Value: "arn:aws:iam::012345678901:role/TestRole,arn:aws:iam::012345678901:saml-provider/TestProvider", 170 | }, 171 | }, 172 | }, 173 | }, 174 | }, 175 | }, 176 | }, 177 | roleName: "", 178 | }, 179 | wantRoleArn: "arn:aws:iam::012345678901:role/TestRole", 180 | wantPrincipalArn: "arn:aws:iam::012345678901:saml-provider/TestProvider", 181 | }, 182 | { 183 | name: "returns first role when role attribute are multi and no roleName argument", 184 | args: args{ 185 | samlResponse: SAMLResponse{ 186 | Assertion: Assertion{ 187 | AttributeStatement: AttributeStatement{ 188 | Attributes: []Attribute{ 189 | { 190 | Name: "dummy", 191 | AttributeValues: []AttributeValue{ 192 | { 193 | Value: "dummy", 194 | }, 195 | }, 196 | }, 197 | { 198 | Name: roleAttributeName, 199 | AttributeValues: []AttributeValue{ 200 | { 201 | Value: "arn:aws:iam::012345678901:role/TestRole1,arn:aws:iam::012345678901:saml-provider/TestProvider1", 202 | }, 203 | { 204 | Value: "arn:aws:iam::012345678901:role/TestRole2,arn:aws:iam::012345678901:saml-provider/TestProvider2", 205 | }, 206 | }, 207 | }, 208 | }, 209 | }, 210 | }, 211 | }, 212 | roleName: "", 213 | }, 214 | wantRoleArn: "arn:aws:iam::012345678901:role/TestRole1", 215 | wantPrincipalArn: "arn:aws:iam::012345678901:saml-provider/TestProvider1", 216 | }, 217 | { 218 | name: "returns specify role when role attribute are multi and roleName argument", 219 | args: args{ 220 | samlResponse: SAMLResponse{ 221 | Assertion: Assertion{ 222 | AttributeStatement: AttributeStatement{ 223 | Attributes: []Attribute{ 224 | { 225 | Name: "dummy", 226 | AttributeValues: []AttributeValue{ 227 | { 228 | Value: "dummy", 229 | }, 230 | }, 231 | }, 232 | { 233 | Name: roleAttributeName, 234 | AttributeValues: []AttributeValue{ 235 | { 236 | Value: "arn:aws:iam::012345678901:role/TestRole1,arn:aws:iam::012345678901:saml-provider/TestProvider1", 237 | }, 238 | { 239 | Value: "arn:aws:iam::012345678901:role/TestRole2,arn:aws:iam::012345678901:saml-provider/TestProvider2", 240 | }, 241 | }, 242 | }, 243 | }, 244 | }, 245 | }, 246 | }, 247 | roleName: "TestRole2", 248 | }, 249 | wantRoleArn: "arn:aws:iam::012345678901:role/TestRole2", 250 | wantPrincipalArn: "arn:aws:iam::012345678901:saml-provider/TestProvider2", 251 | }, 252 | { 253 | name: "returns an error when role attribute does not exist", 254 | args: args{ 255 | samlResponse: SAMLResponse{ 256 | Assertion: Assertion{ 257 | AttributeStatement: AttributeStatement{ 258 | Attributes: []Attribute{ 259 | { 260 | Name: "dummy", 261 | AttributeValues: []AttributeValue{ 262 | { 263 | Value: "dummy", 264 | }, 265 | }, 266 | }, 267 | }, 268 | }, 269 | }, 270 | }, 271 | roleName: "", 272 | }, 273 | wantErr: true, 274 | }, 275 | } 276 | for _, tt := range tests { 277 | t.Run(tt.name, func(t *testing.T) { 278 | got, got1, err := ExtractRoleArnAndPrincipalArn(tt.args.samlResponse, tt.args.roleName) 279 | if (err != nil) != tt.wantErr { 280 | t.Errorf("ExtractRoleArnAndPrincipalArn() error = %v, wantErr %v", err, tt.wantErr) 281 | return 282 | } 283 | if got != tt.wantRoleArn { 284 | t.Errorf("ExtractRoleArnAndPrincipalArn() got = %v, wantRoleArn %v", got, tt.wantRoleArn) 285 | } 286 | if got1 != tt.wantPrincipalArn { 287 | t.Errorf("ExtractRoleArnAndPrincipalArn() got1 = %v, wantRoleArn %v", got1, tt.wantPrincipalArn) 288 | } 289 | }) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Package cmd provides assam CLI. 2 | package cmd 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/cybozu/assam/aws" 11 | "github.com/cybozu/assam/config" 12 | "github.com/cybozu/assam/defaults" 13 | "github.com/cybozu/assam/idp" 14 | "github.com/cybozu/assam/prompt" 15 | "github.com/pkg/errors" 16 | "github.com/spf13/cobra" 17 | 18 | "os" 19 | "os/exec" 20 | "os/signal" 21 | "path/filepath" 22 | "strconv" 23 | "syscall" 24 | ) 25 | 26 | // goreleaser embed variables by ldflags 27 | var ( 28 | version = "dev" 29 | commit = "none" 30 | date = "unknown" 31 | ) 32 | 33 | // Execute runs root command 34 | func Execute() { 35 | if err := newRootCmd().Execute(); err != nil { 36 | // Not print an error because cobra.Command prints it. 37 | os.Exit(1) 38 | } 39 | } 40 | 41 | func newRootCmd() *cobra.Command { 42 | var configure bool 43 | var roleName string 44 | var profile string 45 | var web bool 46 | var showVersion bool 47 | 48 | cmd := &cobra.Command{ 49 | Use: "assam", 50 | Short: "assam simplifies AssumeRoleWithSAML with CLI", 51 | Long: `It is difficult to get a credential of AWS when using AssumeRoleWithSAML. This tool simplifies it.`, 52 | SilenceUsage: true, 53 | RunE: func(_ *cobra.Command, _ []string) error { 54 | if showVersion { 55 | printVersion() 56 | return nil 57 | } 58 | 59 | if configure { 60 | err := configureSettings(profile) 61 | if err != nil { 62 | return err 63 | } 64 | return nil 65 | } 66 | 67 | if web { 68 | return openBrowser() 69 | } 70 | 71 | cfg, err := config.NewConfig(profile) 72 | if err != nil { 73 | return errors.Wrap(err, "please run `assam --configure` at the first time") 74 | } 75 | 76 | request, err := aws.CreateSAMLRequest(cfg.AppIDURI) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | ctx, cancel := context.WithCancel(context.Background()) 82 | defer cancel() 83 | 84 | handleSignal(cancel) 85 | 86 | azure := idp.NewAzure(request, cfg.AzureTenantID) 87 | base64Response, err := azure.Authenticate(ctx, cfg.ChromeUserDataDir) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | response, err := aws.ParseSAMLResponse(base64Response) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | roleArn, principalArn, err := aws.ExtractRoleArnAndPrincipalArn(*response, roleName) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | credentials, err := aws.AssumeRoleWithSAML(ctx, cfg.DefaultSessionDurationHours, roleArn, principalArn, base64Response) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | err = aws.SaveCredentials(profile, *credentials) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | return nil 113 | }, 114 | } 115 | cmd.PersistentFlags().BoolVarP(&configure, "configure", "c", false, "configure initial settings") 116 | cmd.PersistentFlags().StringVarP(&profile, "profile", "p", "default", "AWS profile") 117 | cmd.PersistentFlags().StringVarP(&roleName, "role", "r", "", "AWS IAM role name") 118 | cmd.PersistentFlags().BoolVarP(&web, "web", "w", false, "open AWS management console in a browser") 119 | cmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "Show version") 120 | 121 | return cmd 122 | } 123 | 124 | func printVersion() { 125 | fmt.Printf("version: %s, commit: %s, date: %s\n", version, commit, date) 126 | } 127 | 128 | func configureSettings(profile string) error { 129 | p := prompt.NewPrompt() 130 | 131 | // Load current config. 132 | cfg, err := config.NewConfig(profile) 133 | if err != nil { 134 | cfg = config.Config{} 135 | } 136 | 137 | // Azure Tenant ID 138 | var azureTenantIDOptions prompt.Options 139 | if cfg.AzureTenantID != "" { 140 | azureTenantIDOptions.Default = cfg.AzureTenantID 141 | } 142 | cfg.AzureTenantID, err = p.AskString("Azure Tenant ID", &azureTenantIDOptions) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | // App ID URI 148 | var appIDURIOptions prompt.Options 149 | if cfg.AppIDURI != "" { 150 | appIDURIOptions.Default = cfg.AppIDURI 151 | } 152 | cfg.AppIDURI, err = p.AskString("App ID URI", &appIDURIOptions) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | // Default session duration hours 158 | var defaultSessionDurationHoursOptions prompt.Options 159 | if cfg.DefaultSessionDurationHours != 0 { 160 | defaultSessionDurationHoursOptions.Default = strconv.Itoa(cfg.DefaultSessionDurationHours) 161 | } 162 | defaultSessionDurationHoursOptions.ValidateFunc = func(val string) error { 163 | duration, err := strconv.Atoi(val) 164 | if err != nil || duration < 1 || 12 < duration { 165 | return fmt.Errorf("default session duration hours must be between 1 and 12: %s", val) 166 | } 167 | return nil 168 | } 169 | cfg.DefaultSessionDurationHours, err = p.AskInt("Default Session Duration Hours (1-12)", &defaultSessionDurationHoursOptions) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | // Chrome user data directory 175 | var chromeUserDataDirOptions prompt.Options 176 | if cfg.ChromeUserDataDir != "" { 177 | chromeUserDataDirOptions.Default = cfg.ChromeUserDataDir 178 | } else { 179 | chromeUserDataDirOptions.Default = filepath.Join(defaults.UserHomeDir(), ".config", "assam", "chrome-user-data") 180 | } 181 | cfg.ChromeUserDataDir, err = p.AskString("Chrome User Data Directory", &chromeUserDataDirOptions) 182 | if err != nil { 183 | return err 184 | } 185 | 186 | return config.Save(cfg, profile) 187 | } 188 | 189 | func openBrowser() error { 190 | url, err := aws.NewAWSClient().GetConsoleURL() 191 | if err != nil { 192 | return err 193 | } 194 | 195 | var cmd string 196 | var args []string 197 | switch runtime.GOOS { 198 | case "darwin": 199 | cmd = "open" 200 | args = []string{url} 201 | case "windows": 202 | cmd = "cmd" 203 | args = []string{"/c", "start", strings.ReplaceAll(url, "&", "^&")} // for Windows: "&! <>^|" etc. must be escaped, but since only "&" is used, the corresponding 204 | case "linux": 205 | cmd = "xdg-open" 206 | args = []string{url} 207 | } 208 | 209 | if len(cmd) != 0 { 210 | err = exec.Command(cmd, args...).Run() 211 | if err != nil { 212 | return err 213 | } 214 | } else { 215 | return errors.New("OS does not support -web command") 216 | } 217 | return nil 218 | } 219 | 220 | func handleSignal(cancel context.CancelFunc) { 221 | signalChan := make(chan os.Signal, 1) 222 | signal.Notify(signalChan, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) 223 | 224 | go func() { 225 | for { 226 | <-signalChan 227 | cancel() 228 | } 229 | }() 230 | } 231 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Package config load and save user config. 2 | package config 3 | 4 | import ( 5 | "fmt" 6 | "github.com/aws/aws-sdk-go/aws/defaults" 7 | "gopkg.in/ini.v1" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | ) 12 | 13 | // Config is this tool's configuration 14 | type Config struct { 15 | AppIDURI string 16 | AzureTenantID string 17 | DefaultSessionDurationHours int 18 | ChromeUserDataDir string 19 | } 20 | 21 | const ( 22 | appIDURIKeyName = "app_id_uri" 23 | azureTenantIDKeyName = "azure_tenant_id" 24 | defaultSessionDurationHoursKeyName = "default_session_duration_hours" 25 | chromeUserDataDirKeyName = "chrome_user_data_dir" 26 | ) 27 | 28 | // NewConfig returns Config from default AWS config file 29 | func NewConfig(profile string) (Config, error) { 30 | cfg := Config{} 31 | 32 | f, err := loadConfigFile() 33 | if err != nil { 34 | return cfg, err 35 | } 36 | 37 | section, err := f.GetSection(sectionName(profile)) 38 | if err != nil { 39 | return cfg, err 40 | } 41 | 42 | appIDURIKey, err := section.GetKey(appIDURIKeyName) 43 | if err != nil { 44 | return cfg, err 45 | } 46 | 47 | azureTenantIDKey, err := section.GetKey(azureTenantIDKeyName) 48 | if err != nil { 49 | return cfg, err 50 | } 51 | 52 | defaultSessionDurationHoursKey, err := section.GetKey(defaultSessionDurationHoursKeyName) 53 | if err != nil { 54 | return cfg, err 55 | } 56 | 57 | userDataDirKey, err := section.GetKey(chromeUserDataDirKeyName) 58 | if err != nil { 59 | return cfg, err 60 | } 61 | 62 | cfg.AppIDURI = appIDURIKey.Value() 63 | cfg.AzureTenantID = azureTenantIDKey.Value() 64 | defaultSessionDurationHours, err := strconv.Atoi(defaultSessionDurationHoursKey.Value()) 65 | if err != nil { 66 | return cfg, err 67 | } 68 | cfg.DefaultSessionDurationHours = defaultSessionDurationHours 69 | cfg.ChromeUserDataDir = userDataDirKey.Value() 70 | 71 | return cfg, nil 72 | } 73 | 74 | // Save saves config to file. 75 | func Save(cfg Config, profile string) error { 76 | f, err := loadConfigFile() 77 | if err != nil { 78 | return err 79 | } 80 | 81 | section := f.Section(sectionName(profile)) 82 | 83 | section.Key(appIDURIKeyName).SetValue(cfg.AppIDURI) 84 | section.Key(azureTenantIDKeyName).SetValue(cfg.AzureTenantID) 85 | section.Key(defaultSessionDurationHoursKeyName).SetValue(strconv.Itoa(cfg.DefaultSessionDurationHours)) 86 | section.Key(chromeUserDataDirKeyName).SetValue(cfg.ChromeUserDataDir) 87 | 88 | file := getConfigFilename() 89 | dir := filepath.Dir(file) 90 | err = os.MkdirAll(dir, os.FileMode(0755)) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | return f.SaveTo(file) 96 | } 97 | 98 | func getConfigFilename() string { 99 | // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html 100 | file := os.Getenv("AWS_CONFIG_FILE") 101 | if len(file) == 0 { 102 | file = defaults.SharedConfigFilename() 103 | } 104 | return file 105 | } 106 | 107 | func loadConfigFile() (*ini.File, error) { 108 | file := getConfigFilename() 109 | return ini.LooseLoad(file) 110 | } 111 | 112 | func sectionName(profile string) string { 113 | if profile == "default" { 114 | return profile 115 | } 116 | return fmt.Sprintf("profile %s", profile) 117 | } 118 | -------------------------------------------------------------------------------- /defaults/defaults.go: -------------------------------------------------------------------------------- 1 | // Package defaults get settings. 2 | package defaults 3 | 4 | import ( 5 | "os" 6 | "runtime" 7 | ) 8 | 9 | // UserHomeDir returns user home directory by OS. 10 | func UserHomeDir() string { 11 | if runtime.GOOS == "windows" { 12 | return os.Getenv("USERPROFILE") 13 | } 14 | 15 | return os.Getenv("HOME") 16 | } 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cybozu/assam 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.54.11 7 | github.com/chromedp/cdproto v0.0.0-20240626232640-f933b107c653 8 | github.com/chromedp/chromedp v0.9.5 9 | github.com/google/uuid v1.6.0 10 | github.com/pkg/errors v0.9.1 11 | github.com/spf13/cobra v1.8.1 12 | github.com/stretchr/testify v1.9.0 13 | gopkg.in/ini.v1 v1.67.0 14 | ) 15 | 16 | require ( 17 | github.com/chromedp/sysutil v1.0.0 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/gobwas/httphead v0.1.0 // indirect 20 | github.com/gobwas/pool v0.2.1 // indirect 21 | github.com/gobwas/ws v1.3.2 // indirect 22 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 23 | github.com/jmespath/go-jmespath v0.4.0 // indirect 24 | github.com/josharian/intern v1.0.0 // indirect 25 | github.com/mailru/easyjson v0.7.7 // indirect 26 | github.com/pmezard/go-difflib v1.0.0 // indirect 27 | github.com/spf13/pflag v1.0.5 // indirect 28 | golang.org/x/sys v0.16.0 // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | 32 | // Exclude x/text affected by CVE-2020-28852. 33 | // https://github.com/golang/go/issues/42536 34 | exclude golang.org/x/text v0.15.0 35 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.54.11 h1:Zxuv/R+IVS0B66yz4uezhxH9FN9/G2nbxejYqAMFjxk= 2 | github.com/aws/aws-sdk-go v1.54.11/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 3 | github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= 4 | github.com/chromedp/cdproto v0.0.0-20240626232640-f933b107c653 h1:+X5W4pr9miY1UyYzgaVjqVFqPekWcGtduoAe2NE/MzM= 5 | github.com/chromedp/cdproto v0.0.0-20240626232640-f933b107c653/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= 6 | github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg= 7 | github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= 8 | github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= 9 | github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 15 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 16 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 17 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 18 | github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q= 19 | github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= 20 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 21 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 22 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 23 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 24 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 25 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 26 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 27 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 28 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 29 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 30 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= 31 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 32 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 33 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 34 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= 35 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 36 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 37 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 38 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 39 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 40 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 41 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 42 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 43 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 44 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 45 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 46 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 47 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 48 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 50 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 53 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 54 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 55 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 56 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 57 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 58 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 59 | -------------------------------------------------------------------------------- /idp/azure.go: -------------------------------------------------------------------------------- 1 | // Package idp provides identity provider authentication. 2 | package idp 3 | 4 | import ( 5 | "context" 6 | "encoding/base64" 7 | "fmt" 8 | "net/url" 9 | "os" 10 | 11 | "github.com/chromedp/cdproto/network" 12 | "github.com/chromedp/chromedp" 13 | "github.com/cybozu/assam/aws" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | const ( 18 | loginURLTemplate = "https://login.microsoftonline.com/%s/saml2?SAMLRequest=%s" 19 | ) 20 | 21 | // Azure provides functionality of AzureAD as IdP 22 | type Azure struct { 23 | samlRequest string 24 | tenantID string 25 | msgChan chan *network.EventRequestWillBeSent 26 | } 27 | 28 | // NewAzure returns Azure 29 | func NewAzure(samlRequest string, tenantID string) Azure { 30 | return Azure{ 31 | samlRequest: samlRequest, 32 | tenantID: tenantID, 33 | msgChan: make(chan *network.EventRequestWillBeSent), 34 | } 35 | } 36 | 37 | // Authenticate sends SAML request to Azure and fetches SAML response 38 | func (a *Azure) Authenticate(ctx context.Context, userDataDir string) (string, error) { 39 | ctx, cancel := a.setupContext(userDataDir) 40 | defer cancel() 41 | 42 | // Need network.Enable() to handle network events. 43 | err := chromedp.Run(ctx, network.Enable()) 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | a.listenNetworkRequest(ctx) 49 | 50 | err = a.navigateToLoginURL(ctx) 51 | if err != nil { 52 | return "", err 53 | } 54 | 55 | response, err := a.fetchSAMLResponse(ctx) 56 | if err != nil { 57 | return "", err 58 | } 59 | 60 | // Shut down gracefully to ensure that user data is stored. 61 | err = chromedp.Cancel(ctx) 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | return response, nil 67 | } 68 | 69 | func (a *Azure) setupContext(userDataDir string) (context.Context, context.CancelFunc) { 70 | // Need to expand environment variables because chromedp does not expand. 71 | expandedDir := os.ExpandEnv(userDataDir) 72 | 73 | opts := []chromedp.ExecAllocatorOption{ 74 | chromedp.UserDataDir(expandedDir), 75 | chromedp.NoFirstRun, 76 | chromedp.NoDefaultBrowserCheck, 77 | } 78 | 79 | allocContext, _ := chromedp.NewExecAllocator(context.Background(), opts...) 80 | 81 | return chromedp.NewContext(allocContext) 82 | } 83 | 84 | func (a *Azure) listenNetworkRequest(ctx context.Context) { 85 | chromedp.ListenTarget(ctx, func(v interface{}) { 86 | go func() { 87 | if req, ok := v.(*network.EventRequestWillBeSent); ok { 88 | a.msgChan <- req 89 | } 90 | }() 91 | }) 92 | } 93 | 94 | func (a *Azure) navigateToLoginURL(ctx context.Context) error { 95 | loginURL := fmt.Sprintf(loginURLTemplate, a.tenantID, url.QueryEscape(string(a.samlRequest))) 96 | return chromedp.Run(ctx, chromedp.Navigate(loginURL)) 97 | } 98 | 99 | func (a *Azure) fetchSAMLResponse(ctx context.Context) (string, error) { 100 | for { 101 | var req *network.EventRequestWillBeSent 102 | select { 103 | case <-ctx.Done(): 104 | return "", ctx.Err() 105 | case req = <-a.msgChan: 106 | } 107 | 108 | if req.Request.URL != aws.EndpointURL { 109 | continue 110 | } 111 | 112 | var postData string 113 | for p := range req.Request.PostDataEntries { 114 | postData += req.Request.PostDataEntries[p].Bytes 115 | } 116 | d, err := base64.StdEncoding.DecodeString(postData) 117 | if err != nil { 118 | return "", err 119 | } 120 | 121 | form, err := url.ParseQuery(string(d)) 122 | if err != nil { 123 | return "", err 124 | } 125 | 126 | samlResponse, ok := form["SAMLResponse"] 127 | if !ok || len(a.samlRequest) == 0 { 128 | return "", errors.New("no such key: SAMLResponse") 129 | } 130 | 131 | return samlResponse[0], nil 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main is assam entry point. 2 | package main 3 | 4 | import "github.com/cybozu/assam/cmd" 5 | 6 | func main() { 7 | cmd.Execute() 8 | } 9 | -------------------------------------------------------------------------------- /prompt/prompt.go: -------------------------------------------------------------------------------- 1 | // Package prompt provides CLI prompt. 2 | package prompt 3 | 4 | import ( 5 | "bufio" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // Prompt provides CLI prompt functionality 14 | type Prompt struct { 15 | writer io.Writer 16 | scanner *bufio.Scanner 17 | } 18 | 19 | // Options is option for Prompt 20 | type Options struct { 21 | Default string 22 | ValidateFunc ValidateFunc 23 | } 24 | 25 | // ValidateFunc is function to validate input value 26 | type ValidateFunc func(string) error 27 | 28 | // NewPrompt returns Prompt struct 29 | func NewPrompt() Prompt { 30 | return Prompt{ 31 | writer: os.Stdout, 32 | scanner: bufio.NewScanner(os.Stdin), 33 | } 34 | } 35 | 36 | // AskString asks query and returns input string 37 | func (p *Prompt) AskString(query string, options *Options) (string, error) { 38 | if options == nil { 39 | options = &Options{} 40 | } 41 | 42 | for { 43 | // prompt 44 | err := p.printPrompt(query, options) 45 | if err != nil { 46 | return "", err 47 | } 48 | 49 | // scan 50 | val, err := p.scanString() 51 | if err != nil { 52 | return "", err 53 | } 54 | if val == "" { 55 | return options.Default, nil 56 | } 57 | 58 | // validate 59 | if options.ValidateFunc != nil { 60 | err := options.ValidateFunc(val) 61 | if err != nil { 62 | _, err := fmt.Fprintln(p.writer, err.Error()) 63 | if err != nil { 64 | return "", err 65 | } 66 | continue 67 | } 68 | } 69 | 70 | return val, nil 71 | } 72 | } 73 | 74 | // AskInt asks query and returns input int 75 | func (p *Prompt) AskInt(query string, options *Options) (int, error) { 76 | if options == nil { 77 | options = &Options{} 78 | } 79 | 80 | for { 81 | // prompt 82 | err := p.printPrompt(query, options) 83 | if err != nil { 84 | return 0, err 85 | } 86 | 87 | // scan 88 | val, err := p.scanString() 89 | if err != nil { 90 | return 0, err 91 | } 92 | if val == "" { 93 | return strconv.Atoi(options.Default) 94 | } 95 | 96 | // validate 97 | if options.ValidateFunc != nil { 98 | err := options.ValidateFunc(val) 99 | if err != nil { 100 | _, err := fmt.Fprintln(p.writer, err.Error()) 101 | if err != nil { 102 | return 0, err 103 | } 104 | continue 105 | } 106 | } 107 | 108 | return strconv.Atoi(val) 109 | } 110 | } 111 | 112 | func (p *Prompt) printPrompt(query string, options *Options) error { 113 | if options.Default != "" { 114 | _, err := fmt.Fprintf(p.writer, "%s (Default: %s): ", query, options.Default) 115 | if err != nil { 116 | return err 117 | } 118 | } else { 119 | _, err := fmt.Fprintf(p.writer, "%s: ", query) 120 | if err != nil { 121 | return err 122 | } 123 | } 124 | return nil 125 | } 126 | 127 | func (p *Prompt) scanString() (string, error) { 128 | if !p.scanner.Scan() { 129 | return "", p.scanner.Err() 130 | } 131 | 132 | return strings.TrimSpace(p.scanner.Text()), nil 133 | } 134 | -------------------------------------------------------------------------------- /prompt/prompt_test.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "testing" 9 | ) 10 | 11 | func TestPrompt_AskString(t *testing.T) { 12 | type fields struct { 13 | writer io.Writer 14 | scanner *bufio.Scanner 15 | } 16 | type args struct { 17 | query string 18 | options *Options 19 | } 20 | type want struct { 21 | ret string 22 | prompt string 23 | } 24 | tests := []struct { 25 | name string 26 | fields fields 27 | args args 28 | want want 29 | wantErr bool 30 | }{ 31 | { 32 | name: "returns input value without options", 33 | fields: fields{ 34 | writer: new(bytes.Buffer), 35 | scanner: bufio.NewScanner(bytes.NewBufferString("hoge")), 36 | }, 37 | args: args{ 38 | query: "Please input something", 39 | }, 40 | want: want{ 41 | ret: "hoge", 42 | prompt: "Please input something: ", 43 | }, 44 | }, 45 | { 46 | name: "returns trimmed input value without options", 47 | fields: fields{ 48 | writer: new(bytes.Buffer), 49 | scanner: bufio.NewScanner(bytes.NewBufferString(" hoge ")), 50 | }, 51 | args: args{ 52 | query: "Please input something", 53 | }, 54 | want: want{ 55 | ret: "hoge", 56 | prompt: "Please input something: ", 57 | }, 58 | }, 59 | { 60 | name: "returns default value when input is empty", 61 | fields: fields{ 62 | writer: new(bytes.Buffer), 63 | scanner: bufio.NewScanner(bytes.NewBufferString("")), 64 | }, 65 | args: args{ 66 | query: "Please input something", 67 | options: &Options{ 68 | Default: "default value", 69 | }, 70 | }, 71 | want: want{ 72 | ret: "default value", 73 | prompt: "Please input something (Default: default value): ", 74 | }, 75 | }, 76 | { 77 | name: "returns valid value when ValidateFunc is specified", 78 | fields: fields{ 79 | writer: new(bytes.Buffer), 80 | scanner: bufio.NewScanner(bytes.NewBufferString("invalid\nvalid")), 81 | }, 82 | args: args{ 83 | query: "Please input something", 84 | options: &Options{ 85 | ValidateFunc: func(s string) error { 86 | if s != "valid" { 87 | return fmt.Errorf("invalid value: %s", s) 88 | } 89 | return nil 90 | }, 91 | }, 92 | }, 93 | want: want{ 94 | ret: "valid", 95 | prompt: "Please input something: invalid value: invalid\nPlease input something: ", 96 | }, 97 | }, 98 | } 99 | for _, tt := range tests { 100 | t.Run(tt.name, func(t *testing.T) { 101 | p := &Prompt{ 102 | writer: tt.fields.writer, 103 | scanner: tt.fields.scanner, 104 | } 105 | got, err := p.AskString(tt.args.query, tt.args.options) 106 | if (err != nil) != tt.wantErr { 107 | t.Errorf("Prompt.AskString() error = %v, wantErr %v", err, tt.wantErr) 108 | return 109 | } 110 | if got != tt.want.ret { 111 | t.Errorf("Prompt.AskString() = '%v', want '%v'", got, tt.want.ret) 112 | } 113 | actualPrompt := tt.fields.writer.(*bytes.Buffer).Bytes() 114 | expectedPrompt := []byte(tt.want.prompt) 115 | if !bytes.Equal(actualPrompt, expectedPrompt) { 116 | t.Errorf("Prompt of Prompt.AskString() = '%s', want '%s'", actualPrompt, expectedPrompt) 117 | } 118 | }) 119 | } 120 | } 121 | 122 | func TestPrompt_AskInt(t *testing.T) { 123 | type fields struct { 124 | writer io.Writer 125 | scanner *bufio.Scanner 126 | } 127 | type args struct { 128 | query string 129 | options *Options 130 | } 131 | type want struct { 132 | ret int 133 | prompt string 134 | } 135 | tests := []struct { 136 | name string 137 | fields fields 138 | args args 139 | want want 140 | wantErr bool 141 | }{ 142 | { 143 | name: "returns input value without options", 144 | fields: fields{ 145 | writer: new(bytes.Buffer), 146 | scanner: bufio.NewScanner(bytes.NewBufferString("123")), 147 | }, 148 | args: args{ 149 | query: "Please input something", 150 | }, 151 | want: want{ 152 | ret: 123, 153 | prompt: "Please input something: ", 154 | }, 155 | }, 156 | { 157 | name: "returns trimmed input value without options", 158 | fields: fields{ 159 | writer: new(bytes.Buffer), 160 | scanner: bufio.NewScanner(bytes.NewBufferString(" 123 ")), 161 | }, 162 | args: args{ 163 | query: "Please input something", 164 | }, 165 | want: want{ 166 | ret: 123, 167 | prompt: "Please input something: ", 168 | }, 169 | }, 170 | { 171 | name: "returns default value when input is empty", 172 | fields: fields{ 173 | writer: new(bytes.Buffer), 174 | scanner: bufio.NewScanner(bytes.NewBufferString("")), 175 | }, 176 | args: args{ 177 | query: "Please input something", 178 | options: &Options{ 179 | Default: "999", 180 | }, 181 | }, 182 | want: want{ 183 | ret: 999, 184 | prompt: "Please input something (Default: 999): ", 185 | }, 186 | }, 187 | { 188 | name: "returns valid value when ValidateFunc is specified", 189 | fields: fields{ 190 | writer: new(bytes.Buffer), 191 | scanner: bufio.NewScanner(bytes.NewBufferString("99\n100")), 192 | }, 193 | args: args{ 194 | query: "Please input something", 195 | options: &Options{ 196 | ValidateFunc: func(s string) error { 197 | if s != "100" { 198 | return fmt.Errorf("invalid value: %s", s) 199 | } 200 | return nil 201 | }, 202 | }, 203 | }, 204 | want: want{ 205 | ret: 100, 206 | prompt: "Please input something: invalid value: 99\nPlease input something: ", 207 | }, 208 | }, 209 | } 210 | for _, tt := range tests { 211 | t.Run(tt.name, func(t *testing.T) { 212 | p := &Prompt{ 213 | writer: tt.fields.writer, 214 | scanner: tt.fields.scanner, 215 | } 216 | got, err := p.AskInt(tt.args.query, tt.args.options) 217 | if (err != nil) != tt.wantErr { 218 | t.Errorf("Prompt.AskString() error = %v, wantErr %v", err, tt.wantErr) 219 | return 220 | } 221 | if got != tt.want.ret { 222 | t.Errorf("Prompt.AskString() = '%v', want '%v'", got, tt.want.ret) 223 | } 224 | actualPrompt := tt.fields.writer.(*bytes.Buffer).Bytes() 225 | expectedPrompt := []byte(tt.want.prompt) 226 | if !bytes.Equal(actualPrompt, expectedPrompt) { 227 | t.Errorf("Prompt of Prompt.AskString() = '%s', want '%s'", actualPrompt, expectedPrompt) 228 | } 229 | }) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | ":label(renovate)", // Add label. 4 | ":prConcurrentLimit10", // Limit to maximum 10 open PRs. 5 | ":timezone(Asia/Tokyo)", 6 | ":enableVulnerabilityAlertsWithLabel(security)", // Raise PR when vulnerability alerts are detected with label security. 7 | ":semanticCommitTypeAll(chore)", // If semantic commits detected, use semantic commit type chore for all 8 | ], 9 | dependencyDashboard: true, 10 | dependencyDashboardLabels: ["renovate"], 11 | "postUpdateOptions": [ 12 | "gomodTidy" // Enable go mod tidy. 13 | ], 14 | "groupName": "all", // Combine pull requests into one 15 | "schedule": ["after 3am on the first day of the month"], // Monthly(before 3am on the first day of the month) is unstable. 16 | packageRules: [ 17 | { 18 | groupName: "golang-version", 19 | matchDatasources: ["golang-version"], 20 | matchManagers: ["gomod"] 21 | }, 22 | { 23 | groupName: "golang-version", 24 | matchManagers: ["asdf"], 25 | matchPackageNames: ["golang"] 26 | }, 27 | ] 28 | } 29 | --------------------------------------------------------------------------------