Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 14 additions & 1 deletion internal/app/context_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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"],
Expand Down
44 changes: 44 additions & 0 deletions internal/app/context_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
3 changes: 3 additions & 0 deletions internal/auth/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 24 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"`
Expand All @@ -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.
Expand All @@ -109,6 +114,8 @@ type Config struct {
SSORegion string
SSOAccountID string
SSORoleName string
OktaOrgURL string
OktaAppID string
Regions []string
FavoriteServices []string
FavoriteContexts []string
Expand Down Expand Up @@ -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)
}
Expand All @@ -157,6 +166,8 @@ type ContextInfo struct {
SSORegion string
SSOAccountID string
SSORoleName string
OktaOrgURL string
OktaAppID string
Regions []string
Current bool
Favorite bool
Expand All @@ -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
}

Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
5 changes: 5 additions & 0 deletions internal/services/aws/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions internal/services/aws/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Loading