├── .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 |
--------------------------------------------------------------------------------