diff --git a/go.mod b/go.mod index 4a5cb8573..0c49fdb3c 100644 --- a/go.mod +++ b/go.mod @@ -25,16 +25,16 @@ require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/aws/aws-sdk-go-v2/config v1.18.15 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.15 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30 // indirect github.com/aws/aws-sdk-go-v2/service/iam v1.19.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.12.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.12.4 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.4 // indirect - github.com/aws/smithy-go v1.13.5 // indirect + github.com/aws/smithy-go v1.13.5 github.com/danieljoos/wincred v1.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dvsekhvalnov/jose2go v1.6.0 // indirect diff --git a/plugins/aws/access_key_test.go b/plugins/aws/access_key_test.go index 43ca67205..cdce82839 100644 --- a/plugins/aws/access_key_test.go +++ b/plugins/aws/access_key_test.go @@ -545,6 +545,77 @@ func TestSourceProfileLoop(t *testing.T) { }) } +func TestSTSProvisionerYieldsOnSSOProfile(t *testing.T) { + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_REGION", "") + configPath := filepath.Join(t.TempDir(), "awsConfig") + t.Setenv("AWS_CONFIG_FILE", configPath) + + file := ini.Empty() + + profileLegacy, err := file.NewSection("profile sso-legacy") + require.NoError(t, err) + _, err = profileLegacy.NewKey("sso_start_url", "https://example.awsapps.com/start") + require.NoError(t, err) + _, err = profileLegacy.NewKey("sso_region", "us-east-1") + require.NoError(t, err) + _, err = profileLegacy.NewKey("sso_account_id", "111111111111") + require.NoError(t, err) + _, err = profileLegacy.NewKey("sso_role_name", "ReadOnly") + require.NoError(t, err) + + ssoSession, err := file.NewSection("sso-session corp") + require.NoError(t, err) + _, err = ssoSession.NewKey("sso_start_url", "https://corp.awsapps.com/start") + require.NoError(t, err) + _, err = ssoSession.NewKey("sso_region", "eu-west-1") + require.NoError(t, err) + + profileSession, err := file.NewSection("profile sso-new") + require.NoError(t, err) + _, err = profileSession.NewKey("sso_session", "corp") + require.NoError(t, err) + _, err = profileSession.NewKey("sso_account_id", "222222222222") + require.NoError(t, err) + _, err = profileSession.NewKey("sso_role_name", "PowerUser") + require.NoError(t, err) + + err = file.SaveTo(configPath) + require.NoError(t, err) + + // When the active profile is SSO-configured, the STS provisioner must yield + // silently — no env vars, no error — so the SSO Profile provisioner can run. + plugintest.TestProvisioner(t, STSProvisioner{ + profileName: "sso-legacy", + newProviderFactory: func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) STSProviderFactory { + return &mockProviderManager{} + }, + }, map[string]plugintest.ProvisionCase{ + "legacy SSO profile yields silently": { + ItemFields: map[sdk.FieldName]string{ + fieldname.AccessKeyID: "AKIAHPIZFMD5EEXAMPLE", + fieldname.SecretAccessKey: "lBfKB7P5ScmpxDeRoFLZvhJbqNGPoV0vIEXAMPLE", + }, + ExpectedOutput: sdk.ProvisionOutput{}, + }, + }) + + plugintest.TestProvisioner(t, STSProvisioner{ + profileName: "sso-new", + newProviderFactory: func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) STSProviderFactory { + return &mockProviderManager{} + }, + }, map[string]plugintest.ProvisionCase{ + "sso_session profile yields silently": { + ItemFields: map[sdk.FieldName]string{ + fieldname.AccessKeyID: "AKIAHPIZFMD5EEXAMPLE", + fieldname.SecretAccessKey: "lBfKB7P5ScmpxDeRoFLZvhJbqNGPoV0vIEXAMPLE", + }, + ExpectedOutput: sdk.ProvisionOutput{}, + }, + }) +} + func TestResolveLocalAnd1PasswordConfigurations(t *testing.T) { for _, scenario := range []struct { description string diff --git a/plugins/aws/aws.go b/plugins/aws/aws.go index c1a767685..5761d3266 100644 --- a/plugins/aws/aws.go +++ b/plugins/aws/aws.go @@ -15,12 +15,37 @@ func AWSCLI() schema.Executable { NeedsAuth: needsauth.IfAll( needsauth.NotForHelpOrVersion(), needsauth.NotWithoutArgs(), + // Each entry below intentionally bypasses provisioning. Subcommands not listed here + // (e.g. `aws configure import`, `aws configure export-credentials`) still flow through + // the provisioner because they exchange or mutate credentials and benefit from the + // 1Password-managed surface. + // + // `aws sso login`/`logout` write/erase the very token the SSO Profile provisioner reads + // from disk; provisioning before they run creates a chicken-and-egg failure. + needsauth.NotWhenContainsArgs("sso", "login"), + needsauth.NotWhenContainsArgs("sso", "logout"), + // `aws configure sso` and `aws configure sso-session` set up SSO configuration in + // ~/.aws/config; they touch no AWS APIs and need no provisioned credentials. + needsauth.NotWhenContainsArgs("configure", "sso"), + needsauth.NotWhenContainsArgs("configure", "sso-session"), + // `aws configure list` and `list-profiles` are read-only diagnostic queries against + // the local config; `get` and `set` mutate ~/.aws/config or ~/.aws/credentials but + // make no remote API call. Provisioning would be a no-op at best and a confusing + // "missing credentials" error at worst when the user is just inspecting state. + needsauth.NotWhenContainsArgs("configure", "list"), + needsauth.NotWhenContainsArgs("configure", "list-profiles"), + needsauth.NotWhenContainsArgs("configure", "get"), + needsauth.NotWhenContainsArgs("configure", "set"), ), Uses: []schema.CredentialUsage{ { Name: credname.AccessKey, Provisioner: CLIProvisioner{}, }, + { + Name: credname.SSOProfile, + Provisioner: SSOCLIProvisioner{}, + }, }, } } diff --git a/plugins/aws/awslogs.go b/plugins/aws/awslogs.go index 5f5d2609f..bdb4a1e6c 100644 --- a/plugins/aws/awslogs.go +++ b/plugins/aws/awslogs.go @@ -21,6 +21,10 @@ func awslogsCli() schema.Executable { Name: credname.AccessKey, Provisioner: CLIProvisioner{}, }, + { + Name: credname.SSOProfile, + Provisioner: SSOCLIProvisioner{}, + }, }, } } diff --git a/plugins/aws/cache_utils.go b/plugins/aws/cache_utils.go index 9c35326b8..2f00a0285 100644 --- a/plugins/aws/cache_utils.go +++ b/plugins/aws/cache_utils.go @@ -10,6 +10,7 @@ import ( const ( mfaCacheKeyID = "sts-mfa" assumeRoleCacheKeyID = "sts-assume-role" + ssoRoleCacheKeyID = "sso-role" ) // stsCacheWriter writes aws temp credentials to cache using the awsCacheKey @@ -36,3 +37,7 @@ func getRoleCacheKey(roleArn string, accessKeyID string) string { func getMfaCacheKey(accessKeyID string) string { return fmt.Sprintf("%s|%s", mfaCacheKeyID, accessKeyID) } + +func getSSORoleCacheKey(accountID, roleName, sessionKey string) string { + return fmt.Sprintf("%s|%s|%s|%s", ssoRoleCacheKeyID, accountID, roleName, sessionKey) +} diff --git a/plugins/aws/cdk.go b/plugins/aws/cdk.go index 933d8086b..3a7d41818 100644 --- a/plugins/aws/cdk.go +++ b/plugins/aws/cdk.go @@ -21,6 +21,10 @@ func AWSCDKToolkit() schema.Executable { Name: credname.AccessKey, Provisioner: CLIProvisioner{}, }, + { + Name: credname.SSOProfile, + Provisioner: SSOCLIProvisioner{}, + }, }, } } diff --git a/plugins/aws/cli_provisioner.go b/plugins/aws/cli_provisioner.go index f8915885a..f13e7610b 100644 --- a/plugins/aws/cli_provisioner.go +++ b/plugins/aws/cli_provisioner.go @@ -59,3 +59,29 @@ func (p CLIProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput func (p CLIProvisioner) Description() string { return "Provision environment variables with master credentials or temporary STS credentials AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN" } + +// SSOCLIProvisioner provisions SSO role credentials when the AWS CLI is invoked, +// after stripping any --profile flag from the command line so the AWS CLI does +// not try to assume a role on its own. +type SSOCLIProvisioner struct { +} + +func (p SSOCLIProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + profile, editedCommandLine, err := stripAndReturnProfileFlag(out.CommandLine) + if err != nil { + out.AddError(err) + return + } + if editedCommandLine != nil { + out.CommandLine = editedCommandLine + } + NewSSOProvisioner(profile).Provision(ctx, in, out) +} + +func (p SSOCLIProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { + // Nothing to do here: environment variables get wiped automatically when the process exits. +} + +func (p SSOCLIProvisioner) Description() string { + return "Provision environment variables with temporary AWS IAM Identity Center role credentials AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN" +} diff --git a/plugins/aws/eksctl.go b/plugins/aws/eksctl.go index c84ec16b7..6de880bc8 100644 --- a/plugins/aws/eksctl.go +++ b/plugins/aws/eksctl.go @@ -21,6 +21,10 @@ func eksctlCLI() schema.Executable { Name: credname.AccessKey, Provisioner: CLIProvisioner{}, }, + { + Name: credname.SSOProfile, + Provisioner: SSOCLIProvisioner{}, + }, }, } } diff --git a/plugins/aws/plugin.go b/plugins/aws/plugin.go index 98b250db2..ef6c56cf3 100644 --- a/plugins/aws/plugin.go +++ b/plugins/aws/plugin.go @@ -14,6 +14,7 @@ func New() schema.Plugin { }, Credentials: []schema.CredentialType{ AccessKey(), + SSOProfile(), }, Executables: []schema.Executable{ AWSCLI(), diff --git a/plugins/aws/sam.go b/plugins/aws/sam.go index 33286a1c8..ae6f97091 100644 --- a/plugins/aws/sam.go +++ b/plugins/aws/sam.go @@ -26,6 +26,10 @@ func AWSSAMCLI() schema.Executable { Name: credname.AccessKey, Provisioner: CLIProvisioner{}, }, + { + Name: credname.SSOProfile, + Provisioner: SSOCLIProvisioner{}, + }, }, } } diff --git a/plugins/aws/sso_importer.go b/plugins/aws/sso_importer.go new file mode 100644 index 000000000..2ddce9a0a --- /dev/null +++ b/plugins/aws/sso_importer.go @@ -0,0 +1,275 @@ +package aws + +import ( + "context" + "fmt" + "net/url" + "os" + "regexp" + "strings" + "syscall" + + "gopkg.in/ini.v1" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +const ( + profileSectionPrefix = "profile " + ssoSessionSectionPrefix = "sso-session " +) + +// AWS account IDs are exactly 12 decimal digits; AWS regions are lowercase letters with one or two +// dashes separating segments and a trailing digit (e.g. `us-east-1`, `ap-southeast-2`). +var ( + ssoAccountIDRE = regexp.MustCompile(`^[0-9]{12}$`) + ssoRegionRE = regexp.MustCompile(`^[a-z]{2}-[a-z]+-[0-9]+$`) +) + +// TrySSOConfigFile looks for AWS IAM Identity Center profiles in ~/.aws/config. +// It supports both the legacy form (sso_start_url on the profile) and the +// consolidated form (sso_session = NAME referencing an [sso-session NAME] section). +func TrySSOConfigFile() sdk.Importer { + return func(ctx context.Context, in sdk.ImportInput, out *sdk.ImportOutput) { + sourcePath := os.Getenv("AWS_CONFIG_FILE") + envOverride := sourcePath != "" + if !envOverride { + sourcePath = "~/.aws/config" + } + + // Match the SDK convention (sdk/importer/file_importer.go): only `~/` (with slash) is + // expanded against the home directory. A bare `~root/...` form would otherwise be silently + // joined to the *current* user's home, which is misleading at best and security-relevant if + // an attacker can pre-place a file there. + var configPath string + switch { + case strings.HasPrefix(sourcePath, "~/"): + configPath = in.FromHomeDir(strings.TrimPrefix(sourcePath, "~/")) + default: + configPath = in.FromRootDir(sourcePath) + } + + // When `AWS_CONFIG_FILE` is honoured, refuse anything that is not a regular file owned by + // the current user. Defends against a malicious `.envrc` (direnv, asdf, mise) pinning the + // importer to a hostile config file when `op` is run from that directory. + if envOverride { + if err := validateExternalConfigPath(configPath); err != nil { + attempt := out.NewAttempt(importer.SourceFile(sourcePath)) + attempt.AddError(err) + return + } + } + + contents, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return + } + attempt := out.NewAttempt(importer.SourceFile(sourcePath)) + attempt.AddError(err) + return + } + + if len(contents) == 0 { + return + } + + attempt := out.NewAttempt(importer.SourceFile(sourcePath)) + + // Strict botocore-parity loader: `=` is the only key/value delimiter (botocore rejects `:`), + // line continuation is disabled (botocore reads each `\` literally), and `Loose: true` lets + // the loader produce a partial result instead of bricking the entire import on a single + // malformed section. AllowShadows defaults to false, which matches botocore last-wins + // semantics for duplicate `[profile X]` sections; we surface no diagnostic to keep parity. + configFile, err := ini.LoadSources(ini.LoadOptions{ + KeyValueDelimiters: "=", + IgnoreContinuation: true, + Loose: true, + }, contents) + if err != nil { + attempt.AddError(err) + // Fall through; with Loose: true the loader returns a partial *ini.File on most parse + // errors and only short-circuits on truly catastrophic ones. Either way, valid sections + // in the result still produce candidates. + } + if configFile == nil { + return + } + + ssoSessions := collectSSOSessions(configFile) + + for _, section := range configFile.Sections() { + profileName, ok := profileNameFromSection(section.Name()) + if !ok { + continue + } + + fields, err := buildSSOFields(profileName, section, ssoSessions) + if err != nil { + attempt.AddError(err) + continue + } + if fields == nil { + continue + } + + attempt.AddCandidate(sdk.ImportCandidate{ + Fields: fields, + NameHint: importer.SanitizeNameHint(profileName), + }) + } + } +} + +// validateExternalConfigPath enforces the safety properties for an `AWS_CONFIG_FILE` override: +// the path must resolve to a regular file owned by the current user. Anything else is rejected. +func validateExternalConfigPath(path string) error { + fi, err := os.Lstat(path) + if err != nil { + // If the path does not exist, downstream `os.ReadFile` will report it; treat it as a + // non-error here so we do not double-report. + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("AWS_CONFIG_FILE %q: %w", path, err) + } + if fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("AWS_CONFIG_FILE %q is a symlink; refusing to follow", path) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("AWS_CONFIG_FILE %q is not a regular file", path) + } + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + if st.Uid != uint32(os.Geteuid()) { + return fmt.Errorf("AWS_CONFIG_FILE %q is not owned by the current user", path) + } + } + return nil +} + +// profileNameFromSection returns the bare profile name for a [profile NAME] or +// [default] section, and false for any other section type. +func profileNameFromSection(sectionName string) (string, bool) { + if sectionName == defaultProfileName { + return defaultProfileName, true + } + if strings.HasPrefix(sectionName, profileSectionPrefix) { + return strings.TrimPrefix(sectionName, profileSectionPrefix), true + } + return "", false +} + +// collectSSOSessions indexes [sso-session NAME] sections by their bare name. +func collectSSOSessions(configFile *ini.File) map[string]*ini.Section { + sessions := make(map[string]*ini.Section) + for _, section := range configFile.Sections() { + if strings.HasPrefix(section.Name(), ssoSessionSectionPrefix) { + name := strings.TrimPrefix(section.Name(), ssoSessionSectionPrefix) + sessions[name] = section + } + } + return sessions +} + +// buildSSOFields returns the candidate fields for a profile section, or (nil, nil) if the profile +// is not SSO-bearing or is missing required keys. A non-nil error indicates a malformed reference +// or an invalid value (bad URL, malformed account ID, etc.) and should be reported via +// `attempt.AddError`. Errors are scoped to a single profile so other valid profiles still import. +func buildSSOFields(profileName string, section *ini.Section, ssoSessions map[string]*ini.Section) (map[sdk.FieldName]string, error) { + hasSSOSession := keyHasValue(section, "sso_session") + hasLegacyStartURL := keyHasValue(section, "sso_start_url") + if !hasSSOSession && !hasLegacyStartURL { + return nil, nil + } + + fields := make(map[sdk.FieldName]string) + + if hasSSOSession { + sessionName := section.Key("sso_session").Value() + if containsNUL(sessionName) { + return nil, fmt.Errorf("profile %q sso_session value contains a NUL byte", profileName) + } + sessionSection, ok := ssoSessions[sessionName] + if !ok { + return nil, fmt.Errorf("profile %q references unknown sso-session %q", profileName, sessionName) + } + startURL := valueOrEmpty(sessionSection, "sso_start_url") + region := valueOrEmpty(sessionSection, "sso_region") + if startURL == "" || region == "" { + return nil, fmt.Errorf("sso-session %q is missing sso_start_url or sso_region", sessionName) + } + fields[fieldname.SSOStartURL] = startURL + fields[fieldname.SSORegion] = region + fields[fieldname.SSOSession] = sessionName + } else { + fields[fieldname.SSOStartURL] = section.Key("sso_start_url").Value() + fields[fieldname.SSORegion] = valueOrEmpty(section, "sso_region") + } + + if v := valueOrEmpty(section, "sso_account_id"); v != "" { + fields[fieldname.SSOAccountID] = v + } + if v := valueOrEmpty(section, "sso_role_name"); v != "" { + fields[fieldname.SSORoleName] = v + } + if v := valueOrEmpty(section, "region"); v != "" { + fields[fieldname.DefaultRegion] = v + } + + if fields[fieldname.SSOStartURL] == "" || fields[fieldname.SSORegion] == "" || + fields[fieldname.SSOAccountID] == "" || fields[fieldname.SSORoleName] == "" { + return nil, nil + } + + for _, v := range fields { + if containsNUL(v) { + return nil, fmt.Errorf("profile %q contains a NUL byte in one of its SSO fields", profileName) + } + } + + if err := validateSSOStartURL(fields[fieldname.SSOStartURL]); err != nil { + return nil, fmt.Errorf("profile %q: %w", profileName, err) + } + if !ssoAccountIDRE.MatchString(fields[fieldname.SSOAccountID]) { + return nil, fmt.Errorf("profile %q: sso_account_id %q is not a 12-digit AWS account ID", profileName, fields[fieldname.SSOAccountID]) + } + if !ssoRegionRE.MatchString(fields[fieldname.SSORegion]) { + return nil, fmt.Errorf("profile %q: sso_region %q is not a valid AWS region", profileName, fields[fieldname.SSORegion]) + } + + return fields, nil +} + +// validateSSOStartURL enforces an HTTPS scheme and a non-empty host. We do not pin the host to +// `*.awsapps.com`: AWS supports custom SSO start URLs (e.g. `https://signin.aws.amazon.com/...`), +// and an over-tight allowlist would reject legitimate enterprise configurations. +func validateSSOStartURL(s string) error { + u, err := url.Parse(s) + if err != nil { + return fmt.Errorf("sso_start_url %q is not a valid URL: %w", s, err) + } + if u.Scheme != "https" { + return fmt.Errorf("sso_start_url %q must use https://", s) + } + if u.Host == "" { + return fmt.Errorf("sso_start_url %q has no host component", s) + } + return nil +} + +func containsNUL(s string) bool { + return strings.IndexByte(s, 0) >= 0 +} + +func keyHasValue(section *ini.Section, key string) bool { + return section.HasKey(key) && section.Key(key).Value() != "" +} + +func valueOrEmpty(section *ini.Section, key string) string { + if !section.HasKey(key) { + return "" + } + return section.Key(key).Value() +} diff --git a/plugins/aws/sso_importer_test.go b/plugins/aws/sso_importer_test.go new file mode 100644 index 000000000..427664d65 --- /dev/null +++ b/plugins/aws/sso_importer_test.go @@ -0,0 +1,299 @@ +package aws + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestSSOImporter(t *testing.T) { + plugintest.TestImporter(t, SSOProfile().Importer, map[string]plugintest.ImportCase{ + "legacy SSO profile in default config location": { + Files: map[string]string{ + "~/.aws/config": plugintest.LoadFixture(t, "config-sso-legacy"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + NameHint: "sso-legacy", + Fields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://example.awsapps.com/start", + fieldname.SSORegion: "us-east-1", + fieldname.SSOAccountID: "123456789012", + fieldname.SSORoleName: "ReadOnly", + fieldname.DefaultRegion: "us-west-2", + }, + }, + }, + }, + "sso-session profile in default config location": { + Files: map[string]string{ + "~/.aws/config": plugintest.LoadFixture(t, "config-sso-session"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + NameHint: "sso-session", + Fields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://corp.awsapps.com/start", + fieldname.SSORegion: "eu-west-1", + fieldname.SSOAccountID: "210987654321", + fieldname.SSORoleName: "Admin", + fieldname.SSOSession: "corp", + fieldname.DefaultRegion: "eu-west-1", + }, + }, + }, + }, + "mixed config: legacy + sso-session + non-SSO profile": { + Files: map[string]string{ + "~/.aws/config": plugintest.LoadFixture(t, "config-sso-mixed"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + NameHint: "sso-legacy", + Fields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://example.awsapps.com/start", + fieldname.SSORegion: "us-east-1", + fieldname.SSOAccountID: "123456789012", + fieldname.SSORoleName: "ReadOnly", + fieldname.DefaultRegion: "us-west-2", + }, + }, + { + NameHint: "sso-new", + Fields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://corp.awsapps.com/start", + fieldname.SSORegion: "eu-west-1", + fieldname.SSOAccountID: "210987654321", + fieldname.SSORoleName: "Admin", + fieldname.SSOSession: "corp", + fieldname.DefaultRegion: "eu-west-1", + }, + }, + }, + }, + "AWS_CONFIG_FILE env var override in home dir": { + Environment: map[string]string{ + "AWS_CONFIG_FILE": "~/.config-custom-sso", + }, + Files: map[string]string{ + "~/.config-custom-sso": plugintest.LoadFixture(t, "config-sso-legacy"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + NameHint: "sso-legacy", + Fields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://example.awsapps.com/start", + fieldname.SSORegion: "us-east-1", + fieldname.SSOAccountID: "123456789012", + fieldname.SSORoleName: "ReadOnly", + fieldname.DefaultRegion: "us-west-2", + }, + }, + }, + }, + "missing config file": { + Files: map[string]string{}, + ExpectedCandidates: nil, + }, + "empty config file": { + Files: map[string]string{ + "~/.aws/config": "", + }, + ExpectedCandidates: nil, + }, + "profile references unknown sso-session": { + Files: map[string]string{ + "~/.aws/config": "[profile broken]\nsso_session = nonexistent\nsso_account_id = 123456789012\nsso_role_name = ReadOnly\n", + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: sdk.ImportSource{Files: []string{"~/.aws/config"}}, + Diagnostics: sdk.Diagnostics{ + Errors: []sdk.Error{ + {Message: "profile \"broken\" references unknown sso-session \"nonexistent\""}, + }, + }, + }, + }, + }, + }, + "sso_start_url with non-HTTPS scheme is rejected": { + Files: map[string]string{ + "~/.aws/config": "[profile bad-scheme]\nsso_start_url = http://example.awsapps.com/start\nsso_region = us-east-1\nsso_account_id = 123456789012\nsso_role_name = ReadOnly\n", + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: sdk.ImportSource{Files: []string{"~/.aws/config"}}, + Diagnostics: sdk.Diagnostics{ + Errors: []sdk.Error{ + {Message: "profile \"bad-scheme\": sso_start_url \"http://example.awsapps.com/start\" must use https://"}, + }, + }, + }, + }, + }, + }, + "sso_start_url with file:// scheme is rejected": { + Files: map[string]string{ + "~/.aws/config": "[profile file-url]\nsso_start_url = file:///etc/passwd\nsso_region = us-east-1\nsso_account_id = 123456789012\nsso_role_name = ReadOnly\n", + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: sdk.ImportSource{Files: []string{"~/.aws/config"}}, + Diagnostics: sdk.Diagnostics{ + Errors: []sdk.Error{ + {Message: "profile \"file-url\": sso_start_url \"file:///etc/passwd\" must use https://"}, + }, + }, + }, + }, + }, + }, + "sso_account_id is not 12 digits is rejected": { + Files: map[string]string{ + "~/.aws/config": "[profile short-acct]\nsso_start_url = https://example.awsapps.com/start\nsso_region = us-east-1\nsso_account_id = 12345\nsso_role_name = ReadOnly\n", + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: sdk.ImportSource{Files: []string{"~/.aws/config"}}, + Diagnostics: sdk.Diagnostics{ + Errors: []sdk.Error{ + {Message: "profile \"short-acct\": sso_account_id \"12345\" is not a 12-digit AWS account ID"}, + }, + }, + }, + }, + }, + }, + "sso_region with bad characters is rejected": { + Files: map[string]string{ + "~/.aws/config": "[profile bad-region]\nsso_start_url = https://example.awsapps.com/start\nsso_region = us@east-1\nsso_account_id = 123456789012\nsso_role_name = ReadOnly\n", + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: sdk.ImportSource{Files: []string{"~/.aws/config"}}, + Diagnostics: sdk.Diagnostics{ + Errors: []sdk.Error{ + {Message: "profile \"bad-region\": sso_region \"us@east-1\" is not a valid AWS region"}, + }, + }, + }, + }, + }, + }, + "NUL byte in sso_session is rejected": { + Files: map[string]string{ + "~/.aws/config": "[profile nul-session]\nsso_session = corp\x00evil\nsso_account_id = 123456789012\nsso_role_name = ReadOnly\n[sso-session corp]\nsso_start_url = https://example.awsapps.com/start\nsso_region = us-east-1\n", + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: sdk.ImportSource{Files: []string{"~/.aws/config"}}, + Diagnostics: sdk.Diagnostics{ + Errors: []sdk.Error{ + {Message: "profile \"nul-session\" sso_session value contains a NUL byte"}, + }, + }, + }, + }, + }, + }, + "malformed section does not brick valid profiles": { + // `[profile valid]extra-junk` is malformed; with `Loose: true` the loader returns a + // partial result and the well-formed profile still surfaces. + Files: map[string]string{ + "~/.aws/config": "[profile valid]\nsso_start_url = https://example.awsapps.com/start\nsso_region = us-east-1\nsso_account_id = 123456789012\nsso_role_name = ReadOnly\n[profile valid]extra-junk\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + NameHint: "valid", + Fields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://example.awsapps.com/start", + fieldname.SSORegion: "us-east-1", + fieldname.SSOAccountID: "123456789012", + fieldname.SSORoleName: "ReadOnly", + }, + }, + }, + }, + "duplicate profile sections last-wins (botocore parity)": { + // Last-wins matches botocore's `AllowShadows: false` default behaviour. Tested here + // so a future loader-option change cannot silently flip the semantics. + Files: map[string]string{ + "~/.aws/config": "[profile dup]\nsso_start_url = https://first.awsapps.com/start\nsso_region = us-east-1\nsso_account_id = 111111111111\nsso_role_name = ReadOnly\n[profile dup]\nsso_start_url = https://second.awsapps.com/start\nsso_region = eu-west-1\nsso_account_id = 222222222222\nsso_role_name = Admin\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + NameHint: "dup", + Fields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://second.awsapps.com/start", + fieldname.SSORegion: "eu-west-1", + fieldname.SSOAccountID: "222222222222", + fieldname.SSORoleName: "Admin", + }, + }, + }, + }, + }) +} + +// TestExternalConfigPathValidation exercises validateExternalConfigPath directly so the AWS_CONFIG_FILE +// safety properties (regular file, owned by current user, not a symlink) can be tested without +// stand-up cost in the plugintest harness. +func TestExternalConfigPathValidation(t *testing.T) { + dir := t.TempDir() + + t.Run("regular file owned by current user is accepted", func(t *testing.T) { + path := filepath.Join(dir, "ok-config") + if err := os.WriteFile(path, []byte("[profile x]\n"), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + if err := validateExternalConfigPath(path); err != nil { + t.Errorf("expected nil error, got %v", err) + } + }) + + t.Run("non-existent path is allowed (downstream ReadFile reports it)", func(t *testing.T) { + if err := validateExternalConfigPath(filepath.Join(dir, "does-not-exist")); err != nil { + t.Errorf("expected nil error for non-existent path, got %v", err) + } + }) + + t.Run("symlink is refused", func(t *testing.T) { + target := filepath.Join(dir, "symlink-target") + if err := os.WriteFile(target, []byte("[profile x]\n"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + link := filepath.Join(dir, "symlink-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create symlink (e.g. unprivileged Windows): %v", err) + } + err := validateExternalConfigPath(link) + if err == nil { + t.Fatal("expected symlink rejection, got nil") + } + if !strings.Contains(err.Error(), "symlink") { + t.Errorf("expected error message to mention symlink, got %v", err) + } + }) + + t.Run("directory is refused", func(t *testing.T) { + err := validateExternalConfigPath(dir) + if err == nil { + t.Fatal("expected directory rejection, got nil") + } + if !strings.Contains(err.Error(), "regular file") { + t.Errorf("expected error message to mention regular file, got %v", err) + } + }) +} diff --git a/plugins/aws/sso_profile.go b/plugins/aws/sso_profile.go new file mode 100644 index 000000000..4e94d4dc1 --- /dev/null +++ b/plugins/aws/sso_profile.go @@ -0,0 +1,59 @@ +package aws + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func SSOProfile() schema.CredentialType { + return schema.CredentialType{ + Name: credname.SSOProfile, + DocsURL: sdk.URL("https://docs.aws.amazon.com/cli/latest/userguide/sso-configure-profile-token.html"), + ManagementURL: sdk.URL("https://console.aws.amazon.com/iamidentitycenter"), + Fields: []schema.CredentialField{ + { + Name: fieldname.SSOStartURL, + MarkdownDescription: "The AWS access portal URL for your organization, e.g. `https://your-org.awsapps.com/start`. Found in the IAM Identity Center console under Settings.", + }, + { + Name: fieldname.SSORegion, + MarkdownDescription: "The AWS region where IAM Identity Center is configured (e.g. `us-east-1`). Shown next to the access portal URL in the IAM Identity Center console.", + }, + { + Name: fieldname.SSOAccountID, + MarkdownDescription: "The 12-digit AWS account ID to sign in to. Listed next to each account in the AWS access portal.", + Composition: &schema.ValueComposition{ + Length: 12, + Charset: schema.Charset{ + Digits: true, + }, + }, + }, + { + Name: fieldname.SSORoleName, + MarkdownDescription: "The role to assume in the account, e.g. `AdministratorAccess`. Listed under each account in the AWS access portal.", + }, + { + Name: fieldname.SSOSession, + MarkdownDescription: "Optional. The shared `sso-session` name from your `~/.aws/config` file. Leave blank if you use the legacy per-profile format.", + Optional: true, + }, + { + Name: fieldname.DefaultRegion, + MarkdownDescription: "Optional. The AWS region to use for API calls (e.g. `us-east-1`).", + Optional: true, + }, + }, + DefaultProvisioner: NewSSOProvisioner(""), + Importer: importer.TryAll( + TrySSOConfigFile(), + ), + // The SSO bearer token lives in `~/.aws/sso/cache/.json` (written by `aws sso login`), + // so the vault item only stores configuration. Opt out of the "must have at least one secret + // field" validator instead of relaxing it globally. + AllowsExternalSecretCache: true, + } +} diff --git a/plugins/aws/sso_provisioner.go b/plugins/aws/sso_provisioner.go new file mode 100644 index 000000000..6c892560f --- /dev/null +++ b/plugins/aws/sso_provisioner.go @@ -0,0 +1,329 @@ +package aws + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "strings" + "syscall" + "time" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" + confighelpers "github.com/99designs/aws-vault/v7/vault" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" + "github.com/aws/aws-sdk-go-v2/service/sso" + smithy "github.com/aws/smithy-go" +) + +// ssoRetrieveTimeout caps a single sso:GetRoleCredentials round-trip. The SSO endpoint can be +// reached via an attacker-influenceable region if the local AWS config is compromised; a per-call +// deadline ensures one bad config cannot wedge the plugin indefinitely. +const ssoRetrieveTimeout = 30 * time.Second + +// SSOProvisioner provisions short-lived AWS credentials by exchanging an SSO access token +// (cached by `aws sso login`) for role credentials via sso:GetRoleCredentials. +type SSOProvisioner struct { + profileName string + newProviderFactory func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) SSOProviderFactory +} + +func NewSSOProvisioner(profileName string) SSOProvisioner { + return SSOProvisioner{ + profileName: profileName, + newProviderFactory: func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) SSOProviderFactory { + return &SSOCacheProviderFactory{ + InCache: cacheState, + OutCache: cacheOps, + ItemFields: fields, + } + }, + } +} + +func (p SSOProvisioner) getProfile() (string, error) { + if len(p.profileName) != 0 { + return p.profileName, nil + } + + if profile := os.Getenv("AWS_PROFILE"); profile != "" { + return profile, nil + } + + return defaultProfileName, nil +} + +func (p SSOProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + profile, err := p.getProfile() + if err != nil { + out.AddError(err) + return + } + + awsConfig, err := ExecuteSilently(getAWSAuthConfigurationForProfile)(profile) + if err != nil { + out.AddError(err) + return + } + + // If the selected profile is not configured for AWS IAM Identity Center (SSO), + // this provisioner is not the right one to handle the request. Yield silently + // so the Access Key provisioner — which the user has linked in the same `Uses` + // block — can supply credentials. + if !awsConfig.HasSSOStartURL() && !awsConfig.HasSSOSession() { + return + } + + if err := resolveLocalAnd1PasswordSSOConfigurations(in.ItemFields, awsConfig); err != nil { + out.AddError(err) + return + } + + if missing := missingRequiredSSOFields(awsConfig); len(missing) > 0 { + out.AddError(fmt.Errorf("missing required field(s) for AWS SSO: %s; add them to the 1Password item or to profile %q in your AWS config file", strings.Join(missing, ", "), profile)) + return + } + + factory := p.newProviderFactory(in.Cache, out.Cache, in.ItemFields) + credsProvider := factory.NewSSORoleCredentialsProvider(awsConfig) + + retrieveCtx, cancel := context.WithTimeout(ctx, ssoRetrieveTimeout) + defer cancel() + + creds, err := ExecuteSilently(credsProvider.Retrieve)(retrieveCtx) + if err != nil { + out.AddError(translateSSORetrieveError(err, profile)) + return + } + + out.AddEnvVar("AWS_ACCESS_KEY_ID", creds.AccessKeyID) + out.AddEnvVar("AWS_SECRET_ACCESS_KEY", creds.SecretAccessKey) + if creds.SessionToken != "" { + out.AddEnvVar("AWS_SESSION_TOKEN", creds.SessionToken) + } + if awsConfig.Region != "" { + out.AddEnvVar("AWS_DEFAULT_REGION", awsConfig.Region) + } +} + +func (p SSOProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { + // Nothing to do here: environment variables get wiped automatically when the process exits. +} + +func (p SSOProvisioner) Description() string { + return "Provision environment variables with temporary AWS IAM Identity Center role credentials AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN" +} + +// SSOProviderFactory builds an aws.CredentialsProvider that returns role credentials +// derived from a cached AWS SSO access token. +type SSOProviderFactory interface { + NewSSORoleCredentialsProvider(awsConfig *confighelpers.Config) aws.CredentialsProvider +} + +// SSOCacheProviderFactory wraps the underlying SSO role-credentials provider with the +// shell plugin's encrypted cache so subsequent runs within the credential's TTL avoid +// hitting the SSO endpoint. +type SSOCacheProviderFactory struct { + InCache sdk.CacheState + OutCache sdk.CacheOperations + ItemFields map[sdk.FieldName]string +} + +func (f SSOCacheProviderFactory) NewSSORoleCredentialsProvider(awsConfig *confighelpers.Config) aws.CredentialsProvider { + cacheKey := getSSORoleCacheKey(awsConfig.SSOAccountID, awsConfig.SSORoleName, ssoSessionKey(awsConfig)) + if f.InCache.Has(cacheKey) { + return NewStsCacheProvider(cacheKey, f.InCache) + } + + cachedTokenFilepath, err := ssocreds.StandardCachedTokenFilepath(ssoSessionKey(awsConfig)) + if err != nil { + return errProvider{err: err} + } + + if err := assertSSOTokenCacheSafe(cachedTokenFilepath); err != nil { + return errProvider{err: err} + } + + ssoClient := sso.NewFromConfig(aws.Config{Region: awsConfig.SSORegion}) + + provider := ssocreds.New(ssoClient, awsConfig.SSOAccountID, awsConfig.SSORoleName, awsConfig.SSOStartURL, func(o *ssocreds.Options) { + o.CachedTokenFilepath = cachedTokenFilepath + }) + + return &ssoRoleCacheWritingProvider{ + Provider: provider, + stsCacheWriter: NewSTSCacheWriter(cacheKey, f.OutCache), + } +} + +// ssoSessionKey returns the value used to derive the shared `~/.aws/sso/cache/.json` +// filename. botocore uses the sso_session name when present, otherwise the start URL. +func ssoSessionKey(awsConfig *confighelpers.Config) string { + if awsConfig.HasSSOSession() { + return awsConfig.SSOSession + } + return awsConfig.SSOStartURL +} + +// ssoRoleCacheWritingProvider wraps the SDK's SSO role credentials provider so that +// successful retrievals are persisted in the shell plugin's encrypted cache. +type ssoRoleCacheWritingProvider struct { + *ssocreds.Provider + stsCacheWriter +} + +func (p *ssoRoleCacheWritingProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + creds, err := p.Provider.Retrieve(ctx) + if err != nil { + return aws.Credentials{}, err + } + if err := p.stsCacheWriter.Put(creds); err != nil { + return aws.Credentials{}, err + } + return creds, nil +} + +// errProvider returns a fixed error from Retrieve. It exists so the cache-key derivation +// path can surface filesystem errors through the same code path as a normal Retrieve call. +type errProvider struct { + err error +} + +func (p errProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + return aws.Credentials{}, p.err +} + +// translateSSORetrieveError rewrites token-not-found / token-expired errors from ssocreds into +// a friendly message that points the user at `aws sso login`. For other smithy.APIError variants +// returned by the SSO endpoint, it maps known codes to plugin-controlled strings; unknown codes +// get a generic message so attacker-controlled error text from a hostile endpoint never reaches +// the user-visible UI. Non-smithy errors (e.g. our own filesystem fail-closed errors from +// assertSSOTokenCacheSafe) are passed through unchanged because they are locally produced. +func translateSSORetrieveError(err error, profile string) error { + var invalid *ssocreds.InvalidTokenError + if errors.As(err, &invalid) { + cmd := "aws sso login" + if profile != defaultProfileName { + cmd = fmt.Sprintf("aws sso login --profile %s", profile) + } + return fmt.Errorf("AWS SSO token is missing or expired; run `%s` and try again", cmd) + } + + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + switch apiErr.ErrorCode() { + case "UnauthorizedException": + return fmt.Errorf("AWS SSO rejected the cached access token as unauthorized; run `aws sso login` and try again") + case "ForbiddenException": + return fmt.Errorf("AWS SSO denied access for the configured account/role; verify the assigned permissions in IAM Identity Center") + case "ResourceNotFoundException": + return fmt.Errorf("AWS SSO could not find the configured account or role; verify sso_account_id and sso_role_name") + case "TooManyRequestsException": + return fmt.Errorf("AWS SSO is throttling requests; wait a moment and try again") + default: + // Internally log the original error so an operator can still diagnose; do not surface + // the server-controlled message text in the user-facing error. + log.Printf("aws sso plugin: unexpected SSO API error code %q from sso:GetRoleCredentials", apiErr.ErrorCode()) + return fmt.Errorf("failed to retrieve SSO role credentials; check AWS configuration and try again") + } + } + + return err +} + +// assertSSOTokenCacheSafe enforces fail-closed properties on `~/.aws/sso/cache/.json` +// before the AWS SDK reads it: refuses symlinks (which would let a co-resident attacker +// substitute a forged token), refuses files with group/world-readable bits set (since SSO +// access tokens are bearer credentials), and on Unix refuses files not owned by the current +// user. A non-existent file is allowed: the SDK will surface InvalidTokenError, which our +// caller translates into the friendly "run `aws sso login`" message. +func assertSSOTokenCacheSafe(path string) error { + fi, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("inspecting AWS SSO token cache %q: %w", path, err) + } + if fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("AWS SSO token cache %q is a symlink; refusing to follow", path) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("AWS SSO token cache %q is not a regular file", path) + } + if fi.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("AWS SSO token cache %q is group/world readable (mode %o); chmod 600 it and re-run", path, fi.Mode().Perm()) + } + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + if st.Uid != uint32(os.Geteuid()) { + return fmt.Errorf("AWS SSO token cache %q is not owned by the current user", path) + } + } + return nil +} + +// missingRequiredSSOFields reports which SSO fields are still empty after merging the 1Password +// item with the local AWS config. Returning early with a clear list avoids late, opaque failures +// from ssocreds or the AWS SSO API. +func missingRequiredSSOFields(awsConfig *confighelpers.Config) []string { + var missing []string + if awsConfig.SSOStartURL == "" { + missing = append(missing, "SSO Start URL") + } + if awsConfig.SSORegion == "" { + missing = append(missing, "SSO Region") + } + if awsConfig.SSOAccountID == "" { + missing = append(missing, "SSO Account ID") + } + if awsConfig.SSORoleName == "" { + missing = append(missing, "SSO Role Name") + } + return missing +} + +// resolveLocalAnd1PasswordSSOConfigurations reconciles SSO settings between the 1Password +// item and the local AWS config file using the same rules as the existing Access Key flow: +// values present in only one source are accepted; values present in both must agree. +func resolveLocalAnd1PasswordSSOConfigurations(itemFields map[sdk.FieldName]string, awsConfig *confighelpers.Config) error { + checks := []struct { + name string + fieldName sdk.FieldName + target *string + }{ + {name: "SSO Start URL", fieldName: fieldname.SSOStartURL, target: &awsConfig.SSOStartURL}, + {name: "SSO Region", fieldName: fieldname.SSORegion, target: &awsConfig.SSORegion}, + {name: "SSO Account ID", fieldName: fieldname.SSOAccountID, target: &awsConfig.SSOAccountID}, + {name: "SSO Role Name", fieldName: fieldname.SSORoleName, target: &awsConfig.SSORoleName}, + {name: "SSO Session", fieldName: fieldname.SSOSession, target: &awsConfig.SSOSession}, + } + + for _, c := range checks { + itemVal, has := itemFields[c.fieldName] + if !has || itemVal == "" { + continue + } + if *c.target != "" && *c.target != itemVal { + return fmt.Errorf("your local AWS configuration has a different %s than the one specified in 1Password", c.name) + } + *c.target = itemVal + } + + region, hasRegularRegion := itemFields[fieldname.Region] + defaultRegion, hasDefaultRegion := itemFields[fieldname.DefaultRegion] + if hasDefaultRegion { + region = defaultRegion + } + hasRegion := hasRegularRegion || hasDefaultRegion + if hasRegion && awsConfig.Region != "" && region != awsConfig.Region { + return fmt.Errorf("your local AWS configuration has a different default region than the one specified in 1Password") + } + if awsConfig.Region == "" { + awsConfig.Region = region + } + + return nil +} diff --git a/plugins/aws/sso_provisioner_test.go b/plugins/aws/sso_provisioner_test.go new file mode 100644 index 000000000..19fcba169 --- /dev/null +++ b/plugins/aws/sso_provisioner_test.go @@ -0,0 +1,632 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" + confighelpers "github.com/99designs/aws-vault/v7/vault" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" + smithy "github.com/aws/smithy-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" +) + +func TestSSOProvisioner(t *testing.T) { + t.Run("cache hit legacy form", func(t *testing.T) { + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_REGION", "") + configPath := filepath.Join(t.TempDir(), "awsConfig") + t.Setenv("AWS_CONFIG_FILE", configPath) + + file := ini.Empty() + profile, err := file.NewSection("profile sso-legacy") + require.NoError(t, err) + _, err = profile.NewKey("sso_start_url", "https://example.awsapps.com/start") + require.NoError(t, err) + _, err = profile.NewKey("sso_region", "us-east-1") + require.NoError(t, err) + _, err = profile.NewKey("sso_account_id", "111111111111") + require.NoError(t, err) + _, err = profile.NewKey("sso_role_name", "ReadOnly") + require.NoError(t, err) + _, err = profile.NewKey("region", "us-east-1") + require.NoError(t, err) + require.NoError(t, file.SaveTo(configPath)) + + cachedCreds := aws.Credentials{ + AccessKeyID: "AKIACACHEDLEGACYSSO", + SecretAccessKey: "cachedSecretLegacy/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "cachedSessionTokenLegacy", + CanExpire: true, + Expires: time.Now().Add(30 * time.Minute), + } + cacheKey := getSSORoleCacheKey("111111111111", "ReadOnly", "https://example.awsapps.com/start") + marshaled, err := json.Marshal(cachedCreds) + require.NoError(t, err) + + factory := &mockSSOProviderFactory{} + provisioner := SSOProvisioner{ + profileName: "sso-legacy", + newProviderFactory: func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) SSOProviderFactory { + factory.inCache = cacheState + return factory + }, + } + + in := sdk.ProvisionInput{ + ItemFields: map[sdk.FieldName]string{}, + HomeDir: "~", + TempDir: "/tmp", + Cache: sdk.CacheState{ + cacheKey: sdk.CacheEntry{Data: marshaled, ExpiresAt: cachedCreds.Expires}, + }, + } + out := sdk.ProvisionOutput{ + Environment: make(map[string]string), + Files: make(map[string]sdk.OutputFile), + } + + provisioner.Provision(context.Background(), in, &out) + + require.Empty(t, out.Diagnostics.Errors) + assert.False(t, factory.freshCalled, "fresh SSO retrieval should not run on cache hit") + assert.Equal(t, cachedCreds.AccessKeyID, out.Environment["AWS_ACCESS_KEY_ID"]) + assert.Equal(t, cachedCreds.SecretAccessKey, out.Environment["AWS_SECRET_ACCESS_KEY"]) + assert.Equal(t, cachedCreds.SessionToken, out.Environment["AWS_SESSION_TOKEN"]) + assert.Equal(t, "us-east-1", out.Environment["AWS_DEFAULT_REGION"]) + }) + + t.Run("cache hit sso_session form", func(t *testing.T) { + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_REGION", "") + configPath := filepath.Join(t.TempDir(), "awsConfig") + t.Setenv("AWS_CONFIG_FILE", configPath) + + file := ini.Empty() + profile, err := file.NewSection("profile sso-session-prof") + require.NoError(t, err) + _, err = profile.NewKey("sso_session", "corp") + require.NoError(t, err) + _, err = profile.NewKey("sso_account_id", "222222222222") + require.NoError(t, err) + _, err = profile.NewKey("sso_role_name", "PowerUser") + require.NoError(t, err) + _, err = profile.NewKey("region", "us-west-2") + require.NoError(t, err) + + session, err := file.NewSection("sso-session corp") + require.NoError(t, err) + _, err = session.NewKey("sso_start_url", "https://corp.awsapps.com/start") + require.NoError(t, err) + _, err = session.NewKey("sso_region", "us-west-2") + require.NoError(t, err) + require.NoError(t, file.SaveTo(configPath)) + + cachedCreds := aws.Credentials{ + AccessKeyID: "AKIACACHEDSSOSESSION", + SecretAccessKey: "cachedSecretSession/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "cachedSessionTokenSession", + CanExpire: true, + Expires: time.Now().Add(30 * time.Minute), + } + cacheKey := getSSORoleCacheKey("222222222222", "PowerUser", "corp") + marshaled, err := json.Marshal(cachedCreds) + require.NoError(t, err) + + factory := &mockSSOProviderFactory{} + provisioner := SSOProvisioner{ + profileName: "sso-session-prof", + newProviderFactory: func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) SSOProviderFactory { + factory.inCache = cacheState + return factory + }, + } + + in := sdk.ProvisionInput{ + ItemFields: map[sdk.FieldName]string{}, + HomeDir: "~", + TempDir: "/tmp", + Cache: sdk.CacheState{ + cacheKey: sdk.CacheEntry{Data: marshaled, ExpiresAt: cachedCreds.Expires}, + }, + } + out := sdk.ProvisionOutput{ + Environment: make(map[string]string), + Files: make(map[string]sdk.OutputFile), + } + + provisioner.Provision(context.Background(), in, &out) + + require.Empty(t, out.Diagnostics.Errors) + assert.False(t, factory.freshCalled, "fresh SSO retrieval should not run on cache hit") + assert.Equal(t, cachedCreds.AccessKeyID, out.Environment["AWS_ACCESS_KEY_ID"]) + assert.Equal(t, cachedCreds.SecretAccessKey, out.Environment["AWS_SECRET_ACCESS_KEY"]) + assert.Equal(t, cachedCreds.SessionToken, out.Environment["AWS_SESSION_TOKEN"]) + assert.Equal(t, "us-west-2", out.Environment["AWS_DEFAULT_REGION"]) + }) + + t.Run("cache miss with valid SSO token", func(t *testing.T) { + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_REGION", "") + configPath := filepath.Join(t.TempDir(), "awsConfig") + t.Setenv("AWS_CONFIG_FILE", configPath) + + file := ini.Empty() + profile, err := file.NewSection("profile sso-fresh") + require.NoError(t, err) + _, err = profile.NewKey("sso_start_url", "https://example.awsapps.com/start") + require.NoError(t, err) + _, err = profile.NewKey("sso_region", "us-east-1") + require.NoError(t, err) + _, err = profile.NewKey("sso_account_id", "333333333333") + require.NoError(t, err) + _, err = profile.NewKey("sso_role_name", "Admin") + require.NoError(t, err) + _, err = profile.NewKey("region", "us-east-1") + require.NoError(t, err) + require.NoError(t, file.SaveTo(configPath)) + + plugintest.TestProvisioner(t, SSOProvisioner{ + profileName: "sso-fresh", + newProviderFactory: func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) SSOProviderFactory { + return &mockSSOProviderFactory{provider: mockSSORoleProvider{}} + }, + }, map[string]plugintest.ProvisionCase{ + "emits SSO role credentials": { + ItemFields: map[sdk.FieldName]string{}, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "AWS_ACCESS_KEY_ID": "AKIASSOFRESHCREDS", + "AWS_SECRET_ACCESS_KEY": "ssoFreshSecret/K7MDENG/bPxRfiCYEXAMPLEKEY", + "AWS_SESSION_TOKEN": "ssoFreshSessionToken/K7MDENG/bPxRfiCYEXAMPLEKEY", + "AWS_DEFAULT_REGION": "us-east-1", + }, + }, + }, + }) + }) + + t.Run("cache miss with invalid token error", func(t *testing.T) { + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_REGION", "") + configPath := filepath.Join(t.TempDir(), "awsConfig") + t.Setenv("AWS_CONFIG_FILE", configPath) + + file := ini.Empty() + profile, err := file.NewSection("profile sso-expired") + require.NoError(t, err) + _, err = profile.NewKey("sso_start_url", "https://example.awsapps.com/start") + require.NoError(t, err) + _, err = profile.NewKey("sso_region", "us-east-1") + require.NoError(t, err) + _, err = profile.NewKey("sso_account_id", "444444444444") + require.NoError(t, err) + _, err = profile.NewKey("sso_role_name", "Auditor") + require.NoError(t, err) + require.NoError(t, file.SaveTo(configPath)) + + plugintest.TestProvisioner(t, SSOProvisioner{ + profileName: "sso-expired", + newProviderFactory: func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) SSOProviderFactory { + return &mockSSOProviderFactory{provider: mockSSOInvalidTokenProvider{}} + }, + }, map[string]plugintest.ProvisionCase{ + "surfaces friendly login instruction": { + ItemFields: map[sdk.FieldName]string{}, + ExpectedOutput: sdk.ProvisionOutput{ + Diagnostics: sdk.Diagnostics{Errors: []sdk.Error{{Message: "AWS SSO token is missing or expired; run `aws sso login --profile sso-expired` and try again"}}}, + }, + }, + }) + }) + + t.Run("profile is not SSO", func(t *testing.T) { + // When the active profile is a plain IAM profile, the SSO provisioner must + // yield silently — no env vars, no error — so the Access Key provisioner + // can run. + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_REGION", "") + configPath := filepath.Join(t.TempDir(), "awsConfig") + t.Setenv("AWS_CONFIG_FILE", configPath) + + file := ini.Empty() + profile, err := file.NewSection("profile iam-user") + require.NoError(t, err) + _, err = profile.NewKey("region", "us-east-1") + require.NoError(t, err) + require.NoError(t, file.SaveTo(configPath)) + + plugintest.TestProvisioner(t, SSOProvisioner{ + profileName: "iam-user", + newProviderFactory: func(cacheState sdk.CacheState, cacheOps sdk.CacheOperations, fields map[sdk.FieldName]string) SSOProviderFactory { + return &mockSSOProviderFactory{} + }, + }, map[string]plugintest.ProvisionCase{ + "yields silently": { + ItemFields: map[sdk.FieldName]string{}, + ExpectedOutput: sdk.ProvisionOutput{}, + }, + }) + }) +} + +func TestResolveLocalAnd1PasswordSSOConfigurations(t *testing.T) { + for _, scenario := range []struct { + description string + itemFields map[sdk.FieldName]string + awsConfig *confighelpers.Config + expectedResultingConfig *confighelpers.Config + err error + }{ + { + description: "all SSO fields present only in 1Password", + itemFields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://example.awsapps.com/start", + fieldname.SSORegion: "us-east-1", + fieldname.SSOAccountID: "111111111111", + fieldname.SSORoleName: "ReadOnly", + fieldname.SSOSession: "corp", + }, + awsConfig: &confighelpers.Config{ProfileName: "dev"}, + expectedResultingConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOStartURL: "https://example.awsapps.com/start", + SSORegion: "us-east-1", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + SSOSession: "corp", + }, + }, + { + description: "all SSO fields present only in local config", + itemFields: map[sdk.FieldName]string{}, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOStartURL: "https://example.awsapps.com/start", + SSORegion: "us-east-1", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + }, + expectedResultingConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOStartURL: "https://example.awsapps.com/start", + SSORegion: "us-east-1", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + }, + }, + { + description: "SSO fields agree between 1Password and local config", + itemFields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://example.awsapps.com/start", + fieldname.SSORegion: "us-east-1", + fieldname.SSOAccountID: "111111111111", + fieldname.SSORoleName: "ReadOnly", + }, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOStartURL: "https://example.awsapps.com/start", + SSORegion: "us-east-1", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + }, + expectedResultingConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOStartURL: "https://example.awsapps.com/start", + SSORegion: "us-east-1", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + }, + }, + { + description: "SSO start URL conflict", + itemFields: map[sdk.FieldName]string{ + fieldname.SSOStartURL: "https://other.awsapps.com/start", + }, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOStartURL: "https://example.awsapps.com/start", + }, + err: fmt.Errorf("your local AWS configuration has a different SSO Start URL than the one specified in 1Password"), + }, + { + description: "SSO region conflict", + itemFields: map[sdk.FieldName]string{ + fieldname.SSORegion: "us-west-2", + }, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + SSORegion: "us-east-1", + }, + err: fmt.Errorf("your local AWS configuration has a different SSO Region than the one specified in 1Password"), + }, + { + description: "SSO account ID conflict", + itemFields: map[sdk.FieldName]string{ + fieldname.SSOAccountID: "999999999999", + }, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOAccountID: "111111111111", + }, + err: fmt.Errorf("your local AWS configuration has a different SSO Account ID than the one specified in 1Password"), + }, + { + description: "SSO role name conflict", + itemFields: map[sdk.FieldName]string{ + fieldname.SSORoleName: "Admin", + }, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + SSORoleName: "ReadOnly", + }, + err: fmt.Errorf("your local AWS configuration has a different SSO Role Name than the one specified in 1Password"), + }, + { + description: "SSO session conflict", + itemFields: map[sdk.FieldName]string{ + fieldname.SSOSession: "personal", + }, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOSession: "corp", + }, + err: fmt.Errorf("your local AWS configuration has a different SSO Session than the one specified in 1Password"), + }, + { + description: "default region present only in 1Password", + itemFields: map[sdk.FieldName]string{ + fieldname.DefaultRegion: "us-east-2", + }, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOStartURL: "https://example.awsapps.com/start", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + }, + expectedResultingConfig: &confighelpers.Config{ + ProfileName: "dev", + SSOStartURL: "https://example.awsapps.com/start", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + Region: "us-east-2", + }, + }, + { + description: "default region conflict between 1Password and local config", + itemFields: map[sdk.FieldName]string{ + fieldname.DefaultRegion: "us-east-2", + }, + awsConfig: &confighelpers.Config{ + ProfileName: "dev", + Region: "us-east-1", + }, + err: fmt.Errorf("your local AWS configuration has a different default region than the one specified in 1Password"), + }, + } { + t.Run(scenario.description, func(t *testing.T) { + err := resolveLocalAnd1PasswordSSOConfigurations(scenario.itemFields, scenario.awsConfig) + if scenario.err != nil { + assert.EqualError(t, err, scenario.err.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, scenario.expectedResultingConfig, scenario.awsConfig) + } + }) + } +} + +func TestMissingRequiredSSOFields(t *testing.T) { + for _, scenario := range []struct { + description string + awsConfig *confighelpers.Config + expected []string + }{ + { + description: "all fields present", + awsConfig: &confighelpers.Config{ + SSOStartURL: "https://example.awsapps.com/start", + SSORegion: "us-east-1", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + }, + expected: nil, + }, + { + description: "all fields missing", + awsConfig: &confighelpers.Config{}, + expected: []string{"SSO Start URL", "SSO Region", "SSO Account ID", "SSO Role Name"}, + }, + { + description: "only SSO Start URL missing (orphan sso_session reference)", + awsConfig: &confighelpers.Config{ + SSOSession: "corp", + SSORegion: "us-east-1", + SSOAccountID: "111111111111", + SSORoleName: "ReadOnly", + }, + expected: []string{"SSO Start URL"}, + }, + } { + t.Run(scenario.description, func(t *testing.T) { + assert.Equal(t, scenario.expected, missingRequiredSSOFields(scenario.awsConfig)) + }) + } +} + +type mockSSOProviderFactory struct { + inCache sdk.CacheState + provider aws.CredentialsProvider + freshCalled bool +} + +func (f *mockSSOProviderFactory) NewSSORoleCredentialsProvider(awsConfig *confighelpers.Config) aws.CredentialsProvider { + cacheKey := getSSORoleCacheKey(awsConfig.SSOAccountID, awsConfig.SSORoleName, ssoSessionKey(awsConfig)) + if f.inCache.Has(cacheKey) { + return NewStsCacheProvider(cacheKey, f.inCache) + } + f.freshCalled = true + return f.provider +} + +type mockSSORoleProvider struct{} + +func (mockSSORoleProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + return aws.Credentials{ + AccessKeyID: "AKIASSOFRESHCREDS", + SecretAccessKey: "ssoFreshSecret/K7MDENG/bPxRfiCYEXAMPLEKEY", + SessionToken: "ssoFreshSessionToken/K7MDENG/bPxRfiCYEXAMPLEKEY", + CanExpire: true, + Expires: time.Now().Add(time.Hour), + }, nil +} + +type mockSSOInvalidTokenProvider struct{} + +func (mockSSOInvalidTokenProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + return aws.Credentials{}, &ssocreds.InvalidTokenError{Err: fmt.Errorf("token cache file does not exist")} +} + +// TestAssertSSOTokenCacheSafe exercises the fail-closed properties enforced on +// `~/.aws/sso/cache/.json` before the AWS SDK reads it. +func TestAssertSSOTokenCacheSafe(t *testing.T) { + dir := t.TempDir() + + t.Run("regular 0600 file owned by current user is accepted", func(t *testing.T) { + path := filepath.Join(dir, "ok-token.json") + require.NoError(t, os.WriteFile(path, []byte(`{"accessToken":"x"}`), 0o600)) + assert.NoError(t, assertSSOTokenCacheSafe(path)) + }) + + t.Run("non-existent path is allowed (SDK will surface InvalidTokenError)", func(t *testing.T) { + assert.NoError(t, assertSSOTokenCacheSafe(filepath.Join(dir, "missing.json"))) + }) + + t.Run("symlink is rejected without following", func(t *testing.T) { + target := filepath.Join(dir, "real-token.json") + require.NoError(t, os.WriteFile(target, []byte(`{"accessToken":"forged"}`), 0o600)) + link := filepath.Join(dir, "linked-token.json") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + err := assertSSOTokenCacheSafe(link) + require.Error(t, err) + assert.Contains(t, err.Error(), "symlink") + }) + + t.Run("world-readable file is rejected", func(t *testing.T) { + path := filepath.Join(dir, "world-readable.json") + require.NoError(t, os.WriteFile(path, []byte(`{"accessToken":"x"}`), 0o644)) + err := assertSSOTokenCacheSafe(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "group/world readable") + }) + + t.Run("directory is rejected as not a regular file", func(t *testing.T) { + err := assertSSOTokenCacheSafe(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "regular file") + }) +} + +type stubAPIError struct { + code string + message string +} + +func (e stubAPIError) Error() string { return fmt.Sprintf("api error %s: %s", e.code, e.message) } +func (e stubAPIError) ErrorCode() string { return e.code } +func (e stubAPIError) ErrorMessage() string { return e.message } +func (stubAPIError) ErrorFault() smithy.ErrorFault { return smithy.FaultServer } + +// TestTranslateSSORetrieveError verifies that smithy.APIError instances from the SSO endpoint +// are translated into plugin-controlled strings. The token-leak guard at the end asserts that +// no translated message ever contains substrings that would indicate access-token leakage — +// even when the server-controlled message text contains them. +func TestTranslateSSORetrieveError(t *testing.T) { + hostileMessage := "request body: {\"accessToken\":\"eyJabc.def.ghi\",\"bearer\":\"AKIA\"}" + + cases := []struct { + name string + err error + profile string + wantSubstr string + }{ + { + name: "InvalidTokenError → friendly login instruction (default profile)", + err: &ssocreds.InvalidTokenError{Err: fmt.Errorf("expired")}, + profile: defaultProfileName, + wantSubstr: "AWS SSO token is missing or expired", + }, + { + name: "InvalidTokenError → friendly login instruction (named profile)", + err: &ssocreds.InvalidTokenError{Err: fmt.Errorf("expired")}, + profile: "corp", + wantSubstr: "aws sso login --profile corp", + }, + { + name: "UnauthorizedException → static plugin message", + err: stubAPIError{code: "UnauthorizedException", message: hostileMessage}, + profile: "corp", + wantSubstr: "rejected the cached access token", + }, + { + name: "ForbiddenException → static plugin message", + err: stubAPIError{code: "ForbiddenException", message: hostileMessage}, + profile: "corp", + wantSubstr: "denied access for the configured account/role", + }, + { + name: "ResourceNotFoundException → static plugin message", + err: stubAPIError{code: "ResourceNotFoundException", message: hostileMessage}, + profile: "corp", + wantSubstr: "could not find the configured account or role", + }, + { + name: "TooManyRequestsException → static plugin message", + err: stubAPIError{code: "TooManyRequestsException", message: hostileMessage}, + profile: "corp", + wantSubstr: "throttling", + }, + { + name: "unknown smithy code → generic message, server text suppressed", + err: stubAPIError{code: "MysteryException", message: hostileMessage}, + profile: "corp", + wantSubstr: "check AWS configuration and try again", + }, + } + + // Markers that would only appear if the server-controlled hostileMessage above were echoed + // into the translated user-visible error. Phrased so they do not match the static plugin + // strings we intentionally use (e.g. "access token" in `AWS SSO rejected the cached access + // token...` is a static phrase, not a leak). + tokenLeakRE := regexp.MustCompile(`(eyJ|"accessToken"|"bearer")`) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := translateSSORetrieveError(tc.err, tc.profile) + require.Error(t, out) + assert.Contains(t, out.Error(), tc.wantSubstr) + + // Token-leak guard: under no smithy variant should the translated user-visible + // message expose JWT-shaped or JSON-key-shaped fragments from the hostile message. + if _, isSmithy := tc.err.(stubAPIError); isSmithy { + assert.False(t, tokenLeakRE.MatchString(out.Error()), + "translated message %q must not echo server-controlled token-shaped text", out.Error()) + assert.NotContains(t, out.Error(), hostileMessage, + "translated message must not echo the verbatim server-controlled message text") + } + }) + } +} diff --git a/plugins/aws/sts_provisioner.go b/plugins/aws/sts_provisioner.go index b2181ed1a..b4bd103bb 100644 --- a/plugins/aws/sts_provisioner.go +++ b/plugins/aws/sts_provisioner.go @@ -62,6 +62,14 @@ func (p STSProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, ou return } + // If the selected profile is configured for AWS IAM Identity Center (SSO), + // this provisioner is not the right one to handle the request. Yield + // silently so the SSO Profile provisioner — which the user has linked in + // the same `Uses` block — can supply credentials. + if awsConfig.HasSSOStartURL() || awsConfig.HasSSOSession() { + return + } + cacheProviderFactory := p.newProviderFactory(in.Cache, out.Cache, in.ItemFields) tempCredentialsProvider, err := GetTemporaryCredentialsProviderForProfile(awsConfig, cacheProviderFactory, in.ItemFields) if err != nil { @@ -101,10 +109,6 @@ func GetTemporaryCredentialsProviderForProfile(awsConfig *confighelpers.Config, } unsupportedMessage := "%s is not yet supported by the AWS Shell Plugin. If you would like for this feature to be supported, upvote or take on its issue: %s" - if awsConfig.HasSSOStartURL() || awsConfig.HasSSOSession() { - return nil, fmt.Errorf(unsupportedMessage, "SSO Authentication", "https://github.com/1Password/shell-plugins/issues/210") - } - if awsConfig.HasWebIdentity() { return nil, fmt.Errorf(unsupportedMessage, "Web Identity Authentication", "https://github.com/1Password/shell-plugins/issues/209") } diff --git a/plugins/aws/test-fixtures/config-sso-legacy b/plugins/aws/test-fixtures/config-sso-legacy new file mode 100644 index 000000000..1a9a2556b --- /dev/null +++ b/plugins/aws/test-fixtures/config-sso-legacy @@ -0,0 +1,9 @@ +[default] +region = us-east-1 + +[profile sso-legacy] +sso_start_url = https://example.awsapps.com/start +sso_region = us-east-1 +sso_account_id = 123456789012 +sso_role_name = ReadOnly +region = us-west-2 diff --git a/plugins/aws/test-fixtures/config-sso-mixed b/plugins/aws/test-fixtures/config-sso-mixed new file mode 100644 index 000000000..d37fc81d0 --- /dev/null +++ b/plugins/aws/test-fixtures/config-sso-mixed @@ -0,0 +1,19 @@ +[profile sso-legacy] +sso_start_url = https://example.awsapps.com/start +sso_region = us-east-1 +sso_account_id = 123456789012 +sso_role_name = ReadOnly +region = us-west-2 + +[sso-session corp] +sso_start_url = https://corp.awsapps.com/start +sso_region = eu-west-1 + +[profile sso-new] +sso_session = corp +sso_account_id = 210987654321 +sso_role_name = Admin +region = eu-west-1 + +[profile iam-only] +region = us-east-1 diff --git a/plugins/aws/test-fixtures/config-sso-session b/plugins/aws/test-fixtures/config-sso-session new file mode 100644 index 000000000..4c7cb8c0d --- /dev/null +++ b/plugins/aws/test-fixtures/config-sso-session @@ -0,0 +1,9 @@ +[sso-session corp] +sso_start_url = https://corp.awsapps.com/start +sso_region = eu-west-1 + +[profile sso-session] +sso_session = corp +sso_account_id = 210987654321 +sso_role_name = Admin +region = eu-west-1 diff --git a/sdk/schema/credential_type.go b/sdk/schema/credential_type.go index 8b41ac2b8..ab699aab2 100644 --- a/sdk/schema/credential_type.go +++ b/sdk/schema/credential_type.go @@ -26,6 +26,13 @@ type CredentialType struct { // The default provisioner to use for this credential if the executable doesn't override it. DefaultProvisioner sdk.Provisioner + + // (Optional) Set to true when the credential's secret material lives in an external token cache + // rather than in the 1Password vault item. AWS IAM Identity Center is the canonical example: the + // vault item only stores configuration (start URL, region, account, role), and the bearer token + // is read from `~/.aws/sso/cache/.json` written by `aws sso login`. When this flag is set, + // the "has at least 1 secret field" validator does not require a Secret-true field. + AllowsExternalSecretCache bool } // CredentialField provides the schema of a single field on a credential type. @@ -168,9 +175,13 @@ func (c CredentialType) Validate() (bool, ValidationReport) { Severity: ValidationSeverityError, }) + // Most credentials carry a long-lived secret, but some (e.g. AWS IAM Identity Center) keep their + // token in an external cache and only store configuration in 1Password. Such credentials must + // opt out by setting AllowsExternalSecretCache=true; the assertion stays an Error otherwise so + // the guard rail is preserved for the rest of the catalogue. report.AddCheck(ValidationCheck{ Description: "Has at least 1 field that is secret", - Assertion: hasSecretField, + Assertion: hasSecretField || c.AllowsExternalSecretCache, Severity: ValidationSeverityError, }) diff --git a/sdk/schema/credname/names.go b/sdk/schema/credname/names.go index c230ed51b..acf6c5eb1 100644 --- a/sdk/schema/credname/names.go +++ b/sdk/schema/credname/names.go @@ -21,6 +21,7 @@ const ( PersonalAPIToken = sdk.CredentialName("Personal API Token") PersonalAccessToken = sdk.CredentialName("Personal Access Token") RegistryCredentials = sdk.CredentialName("Registry Credentials") + SSOProfile = sdk.CredentialName("SSO Profile") SecretKey = sdk.CredentialName("Secret Key") UserLogin = sdk.CredentialName("User Login") ) @@ -44,6 +45,7 @@ func ListAll() []sdk.CredentialName { PersonalAPIToken, PersonalAccessToken, RegistryCredentials, + SSOProfile, SecretKey, UserLogin, } diff --git a/sdk/schema/fieldname/names.go b/sdk/schema/fieldname/names.go index 1ffc34a6d..d99141d6b 100644 --- a/sdk/schema/fieldname/names.go +++ b/sdk/schema/fieldname/names.go @@ -49,6 +49,11 @@ const ( ProjectID = sdk.FieldName("Project ID") Project = sdk.FieldName("Project") Region = sdk.FieldName("Region") + SSOAccountID = sdk.FieldName("SSO Account ID") + SSORegion = sdk.FieldName("SSO Region") + SSORoleName = sdk.FieldName("SSO Role Name") + SSOSession = sdk.FieldName("SSO Session") + SSOStartURL = sdk.FieldName("SSO Start URL") Secret = sdk.FieldName("Secret") SecretAccessKey = sdk.FieldName("Secret Access Key") Subdomain = sdk.FieldName("Subdomain") @@ -104,6 +109,11 @@ func ListAll() []sdk.FieldName { ProjectID, Project, Region, + SSOAccountID, + SSORegion, + SSORoleName, + SSOSession, + SSOStartURL, Secret, SecretAccessKey, Token, diff --git a/sdk/schema/plugin.go b/sdk/schema/plugin.go index 5c29fc11f..0235a003e 100644 --- a/sdk/schema/plugin.go +++ b/sdk/schema/plugin.go @@ -73,12 +73,6 @@ func (p Plugin) Validate() (bool, ValidationReport) { Severity: ValidationSeverityError, }) - report.AddCheck(ValidationCheck{ - Description: "Has no more than one credential type defined. Plugins with multiple credential types are not supported yet", - Assertion: len(p.Credentials) <= 1, - Severity: ValidationSeverityError, - }) - report.AddCheck(ValidationCheck{ Description: "Credentials referenced in executables are included in the same plugin definition", Assertion: CredentialReferencesInCredentialList(p),