diff --git a/README.md b/README.md index bc1e469..9ac3914 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ contexts: | `console_login` | Run `aws login` during `unic context setup`, then use the resulting profile-backed console credentials | `profile` | | `assume_role` | Assume a role from a base profile | `profile`, `role_arn` | | `sso` | Use AWS IAM Identity Center / SSO, reusing a valid AWS CLI SSO cache and prompting for login only when needed | `sso_start_url`, and for concrete contexts `sso_account_id`, `sso_role_name`; `profile` is optional | +| `okta_saml` | Okta SAML federation context (config schema only for now; runtime credential exchange is in progress) | `okta_org_url`, `okta_app_id`; optional `role_arn` for a preferred role. Passwords and MFA secrets are never stored in config | The preferred context format separates `auth` from `resources`. `auth.sso_region` controls IAM Identity Center login and role-credential retrieval. `resources.default_region` is selected at startup, and `resources.regions` lists additional regions available from the global `R` region picker. Switching regions reuses the current credentials and recreates only the regional AWS clients. diff --git a/internal/app/context_add.go b/internal/app/context_add.go index 6a577ce..3482eef 100644 --- a/internal/app/context_add.go +++ b/internal/app/context_add.go @@ -15,7 +15,7 @@ type fieldDef struct { required bool } -var authTypes = []string{"sso", "credential", "console_login", "assume_role"} +var authTypes = []string{"sso", "credential", "console_login", "assume_role", "okta_saml"} var fieldsByAuthType = map[string][]fieldDef{ "sso": { @@ -51,6 +51,17 @@ var fieldsByAuthType = map[string][]fieldDef{ {key: "role_arn", label: "Role ARN", required: true}, {key: "external_id", label: "External ID (optional)", required: false}, }, + // okta_saml stores only non-secret metadata; passwords and MFA secrets + // never go into config.yaml. Runtime credential exchange is tracked in #85. + "okta_saml": { + {key: "name", label: "Name", required: true}, + {key: "order", label: "Display Order (optional, lower first)", required: false}, + {key: "region", label: "Region", required: true}, + {key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false}, + {key: "okta_org_url", label: "Okta Org URL (https://acme.okta.com)", required: true}, + {key: "okta_app_id", label: "Okta AWS App ID (from the app embed link)", required: true}, + {key: "role_arn", label: "Preferred Role ARN (optional)", required: false}, + }, } type contextAddedMsg struct{} @@ -143,6 +154,8 @@ func (m Model) saveContext() tea.Cmd { SSORegion: m.addValues["sso_region"], SSOAccountID: m.addValues["sso_account_id"], SSORoleName: m.addValues["sso_role_name"], + OktaOrgURL: m.addValues["okta_org_url"], + OktaAppID: m.addValues["okta_app_id"], }, Resources: &config.ContextResources{ DefaultRegion: m.addValues["region"], diff --git a/internal/app/context_add_test.go b/internal/app/context_add_test.go index d8d774c..d971015 100644 --- a/internal/app/context_add_test.go +++ b/internal/app/context_add_test.go @@ -65,3 +65,47 @@ func TestContextAddKeepsCurrentFieldVisibleOnShortTerminal(t *testing.T) { t.Fatalf("expected windowing indicator on short terminal, got %q", view) } } + +func TestContextAddSelectsOktaSAMLFields(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenContextAdd + m.addStep = 0 + m.addValues = map[string]string{} + + for i, authType := range authTypes { + if authType == "okta_saml" { + m.addAuthIdx = i + break + } + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if model.addValues["auth_type"] != "okta_saml" { + t.Fatalf("expected okta_saml selection, got %q", model.addValues["auth_type"]) + } + + keys := make([]string, 0, len(model.addFields)) + required := map[string]bool{} + for _, field := range model.addFields { + keys = append(keys, field.key) + required[field.key] = field.required + } + joined := strings.Join(keys, ",") + for _, want := range []string{"okta_org_url", "okta_app_id", "role_arn"} { + if !strings.Contains(joined, want) { + t.Fatalf("expected %s field for okta_saml, got %v", want, keys) + } + } + if !required["okta_org_url"] || !required["okta_app_id"] { + t.Fatal("expected okta org URL and app ID to be required") + } + if required["role_arn"] { + t.Fatal("expected preferred role ARN to be optional") + } + for _, key := range keys { + if strings.Contains(key, "password") || strings.Contains(key, "secret") || strings.Contains(key, "mfa") { + t.Fatalf("okta_saml wizard must not collect secrets, got field %q", key) + } + } +} diff --git a/internal/auth/env.go b/internal/auth/env.go index eaad043..a8b6453 100644 --- a/internal/auth/env.go +++ b/internal/auth/env.go @@ -53,6 +53,9 @@ func BuildEnvExports(ctx context.Context, cfg *config.Config) (string, error) { values["AWS_DEFAULT_REGION"] = cfg.Region values["AWS_PROFILE"] = "" + case config.AuthTypeOktaSAML: + return "", fmt.Errorf("context %q uses okta_saml, whose runtime credential exchange is not implemented yet", cfg.ContextName) + case config.AuthTypeCredential, config.AuthTypeConsoleLogin, config.AuthTypeDefault: if cfg.AuthType == config.AuthTypeConsoleLogin { if err := awsservice.ValidateConsoleLoginContext(cfg); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index b8ccb2e..6a7a009 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,7 @@ const ( AuthTypeCredential AuthType = "credential" AuthTypeConsoleLogin AuthType = "console_login" AuthTypeAssumeRole AuthType = "assume_role" + AuthTypeOktaSAML AuthType = "okta_saml" ) // ContextEntry represents a single context definition in config.yaml. @@ -72,6 +73,8 @@ type ContextEntry struct { SSORegion string `yaml:"sso_region,omitempty"` SSOAccountID string `yaml:"sso_account_id,omitempty"` SSORoleName string `yaml:"sso_role_name,omitempty"` + OktaOrgURL string `yaml:"okta_org_url,omitempty"` + OktaAppID string `yaml:"okta_app_id,omitempty"` Regions []string `yaml:"regions,omitempty"` Auth *ContextAuth `yaml:"auth,omitempty"` Resources *ContextResources `yaml:"resources,omitempty"` @@ -87,6 +90,8 @@ type ContextAuth struct { SSORegion string `yaml:"sso_region,omitempty"` SSOAccountID string `yaml:"sso_account_id,omitempty"` SSORoleName string `yaml:"sso_role_name,omitempty"` + OktaOrgURL string `yaml:"okta_org_url,omitempty"` + OktaAppID string `yaml:"okta_app_id,omitempty"` } // ContextResources defines the default and selectable AWS resource regions. @@ -109,6 +114,8 @@ type Config struct { SSORegion string SSOAccountID string SSORoleName string + OktaOrgURL string + OktaAppID string Regions []string FavoriteServices []string FavoriteContexts []string @@ -139,6 +146,8 @@ func normalizeAuthType(value string) AuthType { return AuthTypeConsoleLogin case "assume_role", "assume-role": return AuthTypeAssumeRole + case "okta_saml", "okta-saml": + return AuthTypeOktaSAML default: return AuthType(value) } @@ -157,6 +166,8 @@ type ContextInfo struct { SSORegion string SSOAccountID string SSORoleName string + OktaOrgURL string + OktaAppID string Regions []string Current bool Favorite bool @@ -165,6 +176,7 @@ type ContextInfo struct { type resolvedContextEntry struct { Profile, Region, AuthType, RoleArn, ExternalID string SSOStartURL, SSORegion, SSOAccountID, SSORoleName string + OktaOrgURL, OktaAppID string Regions []string } @@ -174,6 +186,7 @@ func (c ContextEntry) resolved(defaultRegion string) resolvedContextEntry { RoleArn: c.RoleArn, ExternalID: c.ExternalID, SSOStartURL: c.SSOStartURL, SSORegion: c.SSORegion, SSOAccountID: c.SSOAccountID, SSORoleName: c.SSORoleName, + OktaOrgURL: c.OktaOrgURL, OktaAppID: c.OktaAppID, Regions: c.Regions, } if c.Auth != nil { @@ -185,6 +198,8 @@ func (c ContextEntry) resolved(defaultRegion string) resolvedContextEntry { r.SSORegion = c.Auth.SSORegion r.SSOAccountID = c.Auth.SSOAccountID r.SSORoleName = c.Auth.SSORoleName + r.OktaOrgURL = c.Auth.OktaOrgURL + r.OktaAppID = c.Auth.OktaAppID } if c.Resources != nil { r.Region = c.Resources.DefaultRegion @@ -251,7 +266,7 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { } // New format: resolve current context - var contextName, roleArn, externalID, ssoStartURL, ssoRegion, ssoAccountID, ssoRoleName string + var contextName, roleArn, externalID, ssoStartURL, ssoRegion, ssoAccountID, ssoRoleName, oktaOrgURL, oktaAppID string var regions []string var authType AuthType if fc.Current != "" { @@ -271,6 +286,8 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { ssoRegion = resolved.SSORegion ssoAccountID = resolved.SSOAccountID ssoRoleName = resolved.SSORoleName + oktaOrgURL = resolved.OktaOrgURL + oktaAppID = resolved.OktaAppID break } } @@ -307,6 +324,8 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { SSORegion: ssoRegion, SSOAccountID: ssoAccountID, SSORoleName: ssoRoleName, + OktaOrgURL: oktaOrgURL, + OktaAppID: oktaAppID, Regions: regions, FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services), FavoriteContexts: normalizeFavoriteContexts(fc.Favorites.Contexts), @@ -355,6 +374,8 @@ func LoadNamedContext(configPath, name string) (*Config, error) { SSORegion: resolved.SSORegion, SSOAccountID: resolved.SSOAccountID, SSORoleName: resolved.SSORoleName, + OktaOrgURL: resolved.OktaOrgURL, + OktaAppID: resolved.OktaAppID, Regions: resolved.Regions, FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services), FavoriteContexts: normalizeFavoriteContexts(fc.Favorites.Contexts), @@ -436,6 +457,8 @@ func Contexts(configPath string) ([]ContextInfo, error) { SSORegion: resolved.SSORegion, SSOAccountID: resolved.SSOAccountID, SSORoleName: resolved.SSORoleName, + OktaOrgURL: resolved.OktaOrgURL, + OktaAppID: resolved.OktaAppID, Regions: resolved.Regions, Current: ctx.Name == fc.Current, Favorite: favorite, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cda8e54..d26b9ec 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -953,3 +953,63 @@ contexts: t.Fatalf("expected updated region ap-northeast-2, got %q", cfg.Region) } } + +func TestContextWithOktaSAML(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +current: okta-prod +defaults: + region: us-east-1 +contexts: + - name: okta-prod + region: ap-northeast-2 + auth_type: okta_saml + okta_org_url: https://acme.okta.com + okta_app_id: amazon_aws/abc123/272 + - name: okta-structured + auth: + type: okta_saml + okta_org_url: https://acme.okta.com + okta_app_id: amazon_aws/def456/272 + role_arn: arn:aws:iam::111111111111:role/Dev + resources: + default_region: us-east-1 +`) + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if cfg.AuthType != AuthTypeOktaSAML { + t.Errorf("expected okta_saml auth type, got %q", cfg.AuthType) + } + if cfg.OktaOrgURL != "https://acme.okta.com" || cfg.OktaAppID != "amazon_aws/abc123/272" { + t.Errorf("expected flat okta fields, got %q %q", cfg.OktaOrgURL, cfg.OktaAppID) + } + + named, err := LoadNamedContext(path, "okta-structured") + if err != nil { + t.Fatal(err) + } + if named.OktaAppID != "amazon_aws/def456/272" || named.RoleArn != "arn:aws:iam::111111111111:role/Dev" { + t.Errorf("expected structured okta fields, got %+v", named) + } + + contexts, err := Contexts(path) + if err != nil { + t.Fatal(err) + } + for _, ctx := range contexts { + if ctx.Name == "okta-prod" && ctx.OktaOrgURL != "https://acme.okta.com" { + t.Errorf("expected ContextInfo okta org URL, got %+v", ctx) + } + } +} + +func TestNormalizeAuthTypeOktaSAML(t *testing.T) { + if normalizeAuthType("okta_saml") != AuthTypeOktaSAML { + t.Error("okta_saml should normalize to AuthTypeOktaSAML") + } + if normalizeAuthType("okta-saml") != AuthTypeOktaSAML { + t.Error("okta-saml should normalize to AuthTypeOktaSAML") + } +} diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 73dc364..de9adab 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -356,6 +356,11 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, return nil, err } + case config.AuthTypeOktaSAML: + // Schema-only for now: runtime credential exchange lands with the + // Okta SAML provider (#85). + return nil, fmt.Errorf("context %q uses okta_saml, whose runtime credential exchange is not implemented yet", cfg.ContextName) + default: // Legacy / no auth_type — auto-detect from config fields. // When a profile is configured, prefer it over ambient env credentials. diff --git a/internal/services/aws/repository_test.go b/internal/services/aws/repository_test.go index 7e453c0..311b55a 100644 --- a/internal/services/aws/repository_test.go +++ b/internal/services/aws/repository_test.go @@ -4,10 +4,13 @@ import ( "context" "os" "path/filepath" + "strings" "testing" awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" + + "unic/internal/config" ) func TestLoadBaseConfig_ExplicitProfileOverridesEnvCredentials(t *testing.T) { @@ -82,3 +85,14 @@ func TestLoadBaseConfig_UsesEnvCredentialsWhenProfileUnset(t *testing.T) { t.Fatalf("expected env credentials, got %q from %q", creds.AccessKeyID, creds.Source) } } + +func TestNewAwsRepositoryRejectsOktaSAMLForNow(t *testing.T) { + _, err := NewAwsRepository(context.Background(), &config.Config{ + ContextName: "okta-prod", + AuthType: config.AuthTypeOktaSAML, + Region: "us-east-1", + }) + if err == nil || !strings.Contains(err.Error(), "okta_saml") { + t.Fatalf("expected okta_saml not-implemented error, got %v", err) + } +}