From 30e52bfcc3e17b29d261680d4513e32ca94831cf Mon Sep 17 00:00:00 2001 From: Ryan Whitworth Date: Sat, 9 May 2026 02:57:56 -0400 Subject: [PATCH 1/2] feat(aws): add support for AWS IAM Identity Center (SSO) Adds an SSO Profile credential type. The plugin reads the access token cached by `aws sso login` from ~/.aws/sso/cache/.json and exchanges it for short-lived role credentials via sso:GetRoleCredentials. Supports both the legacy `sso_start_url` form and the consolidated `sso_session` form. Role credentials get cached in the plugin's encrypted store, so subsequent invocations within the credential TTL skip the remote call. Access Key and SSO Profile coexist on the same `aws` executable. Each provisioner yields silently when the active profile is configured for the other type, so users can link both items against the same plugin and the right one runs per invocation. The importer scans ~/.aws/config for both forms and surfaces a candidate per SSO-bearing profile. cdk, eksctl, awslogs, and sam also accept the SSO Profile credential. `aws sso login`, `aws sso logout`, `aws configure sso`, `aws configure sso-session`, and the read-only `aws configure list/list-profiles/get/ set` subcommands skip credential provisioning so they can run without an active session. SDK changes: - Allow plugins to expose more than one credential type. The op runtime already accepts this; the validator was the only block. - Downgrade "has at least 1 field that is secret" from error to warning so credential types whose secret lives in an external token cache (SSO, OAuth, gcloud) can be represented without a placeholder field. aws-sdk-go-v2/credentials and aws-sdk-go-v2/service/sso were already on the dependency graph via aws-vault/v7; both move from indirect to direct. Resolves: #210 --- go.mod | 4 +- plugins/aws/access_key_test.go | 71 +++ plugins/aws/aws.go | 18 + plugins/aws/awslogs.go | 4 + plugins/aws/cache_utils.go | 5 + plugins/aws/cdk.go | 4 + plugins/aws/cli_provisioner.go | 26 + plugins/aws/eksctl.go | 4 + plugins/aws/plugin.go | 1 + plugins/aws/sam.go | 4 + plugins/aws/sso_importer.go | 167 +++++++ plugins/aws/sso_importer_test.go | 124 +++++ plugins/aws/sso_profile.go | 55 ++ plugins/aws/sso_provisioner.go | 258 ++++++++++ plugins/aws/sso_provisioner_test.go | 496 +++++++++++++++++++ plugins/aws/sts_provisioner.go | 12 +- plugins/aws/test-fixtures/config-sso-legacy | 9 + plugins/aws/test-fixtures/config-sso-mixed | 19 + plugins/aws/test-fixtures/config-sso-session | 9 + sdk/schema/credential_type.go | 6 +- sdk/schema/credname/names.go | 2 + sdk/schema/fieldname/names.go | 10 + sdk/schema/plugin.go | 6 - 23 files changed, 1301 insertions(+), 13 deletions(-) create mode 100644 plugins/aws/sso_importer.go create mode 100644 plugins/aws/sso_importer_test.go create mode 100644 plugins/aws/sso_profile.go create mode 100644 plugins/aws/sso_provisioner.go create mode 100644 plugins/aws/sso_provisioner_test.go create mode 100644 plugins/aws/test-fixtures/config-sso-legacy create mode 100644 plugins/aws/test-fixtures/config-sso-mixed create mode 100644 plugins/aws/test-fixtures/config-sso-session diff --git a/go.mod b/go.mod index 4a5cb8573..1d9b6f061 100644 --- a/go.mod +++ b/go.mod @@ -25,14 +25,14 @@ 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/danieljoos/wincred v1.1.2 // 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..7bf510206 100644 --- a/plugins/aws/aws.go +++ b/plugins/aws/aws.go @@ -15,12 +15,30 @@ func AWSCLI() schema.Executable { NeedsAuth: needsauth.IfAll( needsauth.NotForHelpOrVersion(), needsauth.NotWithoutArgs(), + // `aws sso login` and `aws sso 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 the + // SSO configuration in ~/.aws/config; no provisioned credentials needed. + needsauth.NotWhenContainsArgs("configure", "sso"), + needsauth.NotWhenContainsArgs("configure", "sso-session"), + // Read-only / diagnostic configure subcommands don't make API calls. + 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..d9b39d50b --- /dev/null +++ b/plugins/aws/sso_importer.go @@ -0,0 +1,167 @@ +package aws + +import ( + "context" + "fmt" + "os" + "strings" + + "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 " +) + +// 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") + if sourcePath == "" { + sourcePath = "~/.aws/config" + } + + configPath := sourcePath + if strings.HasPrefix(configPath, "~") { + configPath = in.FromHomeDir(strings.TrimPrefix(configPath, "~")) + } else { + configPath = in.FromRootDir(configPath) + } + + 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)) + + configFile, err := importer.FileContents(contents).ToINI() + if err != nil { + attempt.AddError(err) + 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), + }) + } + } +} + +// 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 (e.g. unknown sso_session) and should be reported. +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() + 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 + } + + return fields, nil +} + +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..192b29fee --- /dev/null +++ b/plugins/aws/sso_importer_test.go @@ -0,0 +1,124 @@ +package aws + +import ( + "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\""}, + }, + }, + }, + }, + }, + }, + }) +} diff --git a/plugins/aws/sso_profile.go b/plugins/aws/sso_profile.go new file mode 100644 index 000000000..3baed7289 --- /dev/null +++ b/plugins/aws/sso_profile.go @@ -0,0 +1,55 @@ +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(), + ), + } +} diff --git a/plugins/aws/sso_provisioner.go b/plugins/aws/sso_provisioner.go new file mode 100644 index 000000000..332e44da3 --- /dev/null +++ b/plugins/aws/sso_provisioner.go @@ -0,0 +1,258 @@ +package aws + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "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" +) + +// 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) + + creds, err := ExecuteSilently(credsProvider.Retrieve)(ctx) + 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} + } + + 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`. +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) + } + return err +} + +// 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..ae253f3ce --- /dev/null +++ b/plugins/aws/sso_provisioner_test.go @@ -0,0 +1,496 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "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" + "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")} +} 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..1e3cdaf9e 100644 --- a/sdk/schema/credential_type.go +++ b/sdk/schema/credential_type.go @@ -168,10 +168,14 @@ 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. A warning still surfaces the absence so plugin + // authors notice it during review. report.AddCheck(ValidationCheck{ Description: "Has at least 1 field that is secret", Assertion: hasSecretField, - Severity: ValidationSeverityError, + Severity: ValidationSeverityWarning, }) report.AddCheck(ValidationCheck{ 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), From b90170b3443de02bcab7a7d9ad8cc4eafdd2836f Mon Sep 17 00:00:00 2001 From: Ryan Whitworth Date: Sat, 9 May 2026 03:59:18 -0400 Subject: [PATCH 2/2] fix(aws): harden AWS SSO importer and provisioner against hostile inputs Address findings from a pre-PR security review. Each fix is local to the AWS SSO net-new code introduced in the previous commit; pre-existing SDK bugs surfaced by the same review are deferred to a follow-up PR. Importer (plugins/aws/sso_importer.go): - Validate sso_start_url (https + non-empty host) - Regex-check sso_account_id (12 digits) and sso_region - Reject NUL bytes mid-value - Refuse non-regular, symlinked, or non-owned AWS_CONFIG_FILE overrides - Match SDK ~/ prefix convention (bare ~root no longer silently joined) - Use ini.LoadSources directly with strict botocore-parity options: KeyValueDelimiters="=", IgnoreContinuation=true, Loose=true Provisioner (plugins/aws/sso_provisioner.go): - assertSSOTokenCacheSafe rejects symlinks, non-regular files, group/world readable modes, and files not owned by the current uid before passing the path to ssocreds.New - translateSSORetrieveError whitelists known smithy codes (Unauthorized, Forbidden, ResourceNotFound, TooManyRequests); unknown codes get a generic plugin-controlled message so server-controlled error text doesn't reach user-visible output - Wrap sso:GetRoleCredentials in context.WithTimeout(30s) SDK validator (sdk/schema/credential_type.go): - Replace the previous severity downgrade with an opt-in AllowsExternalSecretCache flag on CredentialType. The "must have at least one secret field" check is restored to Error severity globally; only the SSO Profile (whose bearer token lives in the AWS SDK's external cache) opts out Tests (plugins/aws/sso_*_test.go): - Hostile-input cases for the importer (non-HTTPS, file:// scheme, short account ID, malformed region, NUL byte, malformed section header, duplicate-section last-wins) - Direct unit tests for assertSSOTokenCacheSafe and validateExternalConfigPath covering symlink, world-readable, directory, non-existent - Smithy-error translation table with a token-leak guard that asserts no JSON-key-shaped or JWT-shaped fragment from a hostile server message ever appears in the translated user-visible error Deferred to follow-up PR (pre-existing code, out of scope here): - F-2: plugins/registry.go:50-60 GetCredentialType ignores credentialName - F-6: sdk/schema/plugin.go:116-134 MarshalJSON truncates to Credentials[0] - F-7: plugins/aws/cli_provisioner.go:28-53 strip-by-value mis-routing - F-9: sdk/importer/helpers.go:41-53 SanitizeNameHint byte-truncation - F-15: plugins/aws/sts_provisioner.go:399-405 log.SetOutput global state --- go.mod | 2 +- plugins/aws/aws.go | 19 ++- plugins/aws/sso_importer.go | 128 ++++++++++++++++++-- plugins/aws/sso_importer_test.go | 175 ++++++++++++++++++++++++++++ plugins/aws/sso_profile.go | 4 + plugins/aws/sso_provisioner.go | 77 +++++++++++- plugins/aws/sso_provisioner_test.go | 136 +++++++++++++++++++++ sdk/schema/credential_type.go | 19 ++- 8 files changed, 534 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index 1d9b6f061..0c49fdb3c 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( 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 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/aws.go b/plugins/aws/aws.go index 7bf510206..5761d3266 100644 --- a/plugins/aws/aws.go +++ b/plugins/aws/aws.go @@ -15,16 +15,23 @@ func AWSCLI() schema.Executable { NeedsAuth: needsauth.IfAll( needsauth.NotForHelpOrVersion(), needsauth.NotWithoutArgs(), - // `aws sso login` and `aws sso logout` write/erase the very token - // the SSO Profile provisioner reads from disk; provisioning before - // they run creates a chicken-and-egg failure. + // 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 the - // SSO configuration in ~/.aws/config; no provisioned credentials needed. + // `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"), - // Read-only / diagnostic configure subcommands don't make API calls. + // `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"), diff --git a/plugins/aws/sso_importer.go b/plugins/aws/sso_importer.go index d9b39d50b..2ddce9a0a 100644 --- a/plugins/aws/sso_importer.go +++ b/plugins/aws/sso_importer.go @@ -3,8 +3,11 @@ package aws import ( "context" "fmt" + "net/url" "os" + "regexp" "strings" + "syscall" "gopkg.in/ini.v1" @@ -18,21 +21,45 @@ const ( 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") - if sourcePath == "" { + envOverride := sourcePath != "" + if !envOverride { sourcePath = "~/.aws/config" } - configPath := sourcePath - if strings.HasPrefix(configPath, "~") { - configPath = in.FromHomeDir(strings.TrimPrefix(configPath, "~")) - } else { - configPath = in.FromRootDir(configPath) + // 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) @@ -51,9 +78,23 @@ func TrySSOConfigFile() sdk.Importer { attempt := out.NewAttempt(importer.SourceFile(sourcePath)) - configFile, err := importer.FileContents(contents).ToINI() + // 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 } @@ -82,6 +123,32 @@ func TrySSOConfigFile() sdk.Importer { } } +// 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) { @@ -106,9 +173,10 @@ func collectSSOSessions(configFile *ini.File) map[string]*ini.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 (e.g. unknown sso_session) and should be reported. +// 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") @@ -120,6 +188,9 @@ func buildSSOFields(profileName string, section *ini.Section, ssoSessions map[st 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) @@ -152,9 +223,46 @@ func buildSSOFields(profileName string, section *ini.Section, ssoSessions map[st 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() != "" } diff --git a/plugins/aws/sso_importer_test.go b/plugins/aws/sso_importer_test.go index 192b29fee..427664d65 100644 --- a/plugins/aws/sso_importer_test.go +++ b/plugins/aws/sso_importer_test.go @@ -1,6 +1,9 @@ package aws import ( + "os" + "path/filepath" + "strings" "testing" "github.com/1Password/shell-plugins/sdk" @@ -120,5 +123,177 @@ func TestSSOImporter(t *testing.T) { }, }, }, + "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 index 3baed7289..4e94d4dc1 100644 --- a/plugins/aws/sso_profile.go +++ b/plugins/aws/sso_profile.go @@ -51,5 +51,9 @@ func SSOProfile() schema.CredentialType { 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 index 332e44da3..6c892560f 100644 --- a/plugins/aws/sso_provisioner.go +++ b/plugins/aws/sso_provisioner.go @@ -4,8 +4,11 @@ import ( "context" "errors" "fmt" + "log" "os" "strings" + "syscall" + "time" "github.com/1Password/shell-plugins/sdk" "github.com/1Password/shell-plugins/sdk/schema/fieldname" @@ -13,8 +16,14 @@ import ( "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 { @@ -81,7 +90,10 @@ func (p SSOProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, ou factory := p.newProviderFactory(in.Cache, out.Cache, in.ItemFields) credsProvider := factory.NewSSORoleCredentialsProvider(awsConfig) - creds, err := ExecuteSilently(credsProvider.Retrieve)(ctx) + retrieveCtx, cancel := context.WithTimeout(ctx, ssoRetrieveTimeout) + defer cancel() + + creds, err := ExecuteSilently(credsProvider.Retrieve)(retrieveCtx) if err != nil { out.AddError(translateSSORetrieveError(err, profile)) return @@ -131,6 +143,10 @@ func (f SSOCacheProviderFactory) NewSSORoleCredentialsProvider(awsConfig *config 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) { @@ -180,8 +196,12 @@ 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`. +// 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) { @@ -191,9 +211,60 @@ func translateSSORetrieveError(err error, profile string) error { } 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. diff --git a/plugins/aws/sso_provisioner_test.go b/plugins/aws/sso_provisioner_test.go index ae253f3ce..19fcba169 100644 --- a/plugins/aws/sso_provisioner_test.go +++ b/plugins/aws/sso_provisioner_test.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "os" "path/filepath" + "regexp" "testing" "time" @@ -14,6 +16,7 @@ import ( 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" @@ -494,3 +497,136 @@ 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/sdk/schema/credential_type.go b/sdk/schema/credential_type.go index 1e3cdaf9e..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,14 +175,14 @@ 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. A warning still surfaces the absence so plugin - // authors notice it during review. + // 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, - Severity: ValidationSeverityWarning, + Assertion: hasSecretField || c.AllowsExternalSecretCache, + Severity: ValidationSeverityError, }) report.AddCheck(ValidationCheck{