Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,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, optionally with MFA | `profile`, `role_arn`; optional `mfa_serial` |
| `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: `unic env` signs in to Okta (prompt on stderr, password without echo; `UNIC_OKTA_USERNAME`/`UNIC_OKTA_PASSWORD` for automation), exchanges the SAML assertion via `sts:AssumeRoleWithSAML`, and caches the session under `~/.config/unic/cache/okta-saml/`. The TUI reuses a valid cached session passively. Okta MFA challenges are not supported yet | `okta_org_url`, `okta_app_id`; `role_arn` required when the assertion carries multiple roles. Passwords and MFA secrets are never stored |
| `okta_saml` | Okta SAML federation: `unic env` signs in to Okta (prompt on stderr, password without echo; `UNIC_OKTA_USERNAME`/`UNIC_OKTA_PASSWORD` for automation), exchanges the SAML assertion via `sts:AssumeRoleWithSAML`, and caches the session under `~/.config/unic/cache/okta-saml/`. The TUI reuses a valid cached session passively. Okta MFA challenges support TOTP codes and Okta Verify push in v1 | `okta_org_url`, `okta_app_id`; `role_arn` required when the assertion carries multiple roles. Passwords and MFA secrets are never stored |

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. The EC2 Instance Browser can additionally aggregate all configured regions into a single list with `A`.

Expand Down
169 changes: 167 additions & 2 deletions internal/auth/okta.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,23 @@ func promptOktaCredentials(orgURL string) (string, string, error) {

type oktaAuthnResponse struct {
Status string `json:"status"`
StateToken string `json:"stateToken"`
SessionToken string `json:"sessionToken"`
FactorResult string `json:"factorResult"`
Embedded struct {
Factors []oktaFactor `json:"factors"`
} `json:"_embedded"`
}

type oktaFactor struct {
ID string `json:"id"`
FactorType string `json:"factorType"`
Provider string `json:"provider"`
Links struct {
Verify struct {
Href string `json:"href"`
} `json:"verify"`
} `json:"_links"`
}

// oktaPrimaryAuth performs Okta primary authentication and returns a one-time
Expand Down Expand Up @@ -175,13 +191,162 @@ func oktaPrimaryAuth(ctx context.Context, client *http.Client, orgURL, username,
return "", fmt.Errorf("okta returned SUCCESS without a session token")
}
return authn.SessionToken, nil
case "MFA_REQUIRED", "MFA_ENROLL":
return "", fmt.Errorf("okta requires an MFA challenge, which unic does not support yet (see issue #87)")
case "MFA_REQUIRED":
return oktaMFAChallenge(ctx, client, authn)
case "MFA_ENROLL":
return "", fmt.Errorf("okta account has no enrolled MFA factor; enroll one in Okta first")
default:
return "", fmt.Errorf("okta authentication ended in status %q", authn.Status)
}
}

// v1 MFA factor set: TOTP (token:software:totp) and Okta Verify push. TOTP is
// preferred because it completes without waiting; other factor types are
// rejected with an explicit list.
const (
oktaFactorTOTP = "token:software:totp"
oktaFactorPush = "push"
)

var (
promptOktaMFACodeFn = promptOktaMFACode
oktaPushPollInterval = 3 * time.Second
oktaPushPollTimeout = 60 * time.Second
)

func promptOktaMFACode(factor oktaFactor) (string, error) {
fmt.Fprintf(os.Stderr, "Okta MFA code (%s %s): ", factor.Provider, factor.FactorType)
var code string
if _, err := fmt.Fscanln(os.Stdin, &code); err != nil {
return "", fmt.Errorf("failed to read MFA code: %w", err)
}
code = strings.TrimSpace(code)
if code == "" {
return "", fmt.Errorf("MFA code is required")
}
return code, nil
}

func selectOktaFactor(factors []oktaFactor) (oktaFactor, error) {
var push *oktaFactor
for i, factor := range factors {
switch factor.FactorType {
case oktaFactorTOTP:
return factor, nil
case oktaFactorPush:
if push == nil {
push = &factors[i]
}
}
}
if push != nil {
return *push, nil
}
types := make([]string, 0, len(factors))
for _, factor := range factors {
types = append(types, factor.FactorType)
}
return oktaFactor{}, fmt.Errorf("no supported okta MFA factor found (available: %s); unic currently supports %s and %s",
strings.Join(types, ", "), oktaFactorTOTP, oktaFactorPush)
}

func oktaMFAChallenge(ctx context.Context, client *http.Client, authn oktaAuthnResponse) (string, error) {
factor, err := selectOktaFactor(authn.Embedded.Factors)
if err != nil {
return "", err
}
if factor.Links.Verify.Href == "" {
return "", fmt.Errorf("okta factor %s has no verify link", factor.FactorType)
}

switch factor.FactorType {
case oktaFactorTOTP:
code, err := promptOktaMFACodeFn(factor)
if err != nil {
return "", err
}
resp, err := oktaVerifyFactor(ctx, client, factor.Links.Verify.Href, map[string]string{
"stateToken": authn.StateToken,
"passCode": code,
})
if err != nil {
return "", err
}
if resp.Status != "SUCCESS" || resp.SessionToken == "" {
return "", fmt.Errorf("okta MFA verification ended in status %q", resp.Status)
}
return resp.SessionToken, nil

case oktaFactorPush:
fmt.Fprintln(os.Stderr, "Push notification sent to Okta Verify; waiting for approval...")
deadline := time.Now().Add(oktaPushPollTimeout)
payload := map[string]string{"stateToken": authn.StateToken}
for {
resp, err := oktaVerifyFactor(ctx, client, factor.Links.Verify.Href, payload)
if err != nil {
return "", err
}
if resp.Status == "SUCCESS" && resp.SessionToken != "" {
return resp.SessionToken, nil
}
if resp.Status == "MFA_CHALLENGE" && resp.FactorResult == "WAITING" {
if time.Now().After(deadline) {
return "", fmt.Errorf("okta push approval timed out after %s", oktaPushPollTimeout)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(oktaPushPollInterval):
}
continue
}
switch resp.FactorResult {
case "REJECTED":
return "", fmt.Errorf("okta push was rejected")
case "TIMEOUT":
return "", fmt.Errorf("okta push timed out")
default:
return "", fmt.Errorf("okta MFA verification ended in status %q (%s)", resp.Status, resp.FactorResult)
}
}

default:
return "", fmt.Errorf("unsupported okta MFA factor %q", factor.FactorType)
}
}

func oktaVerifyFactor(ctx context.Context, client *http.Client, href string, payload map[string]string) (oktaAuthnResponse, error) {
body, err := json.Marshal(payload)
if err != nil {
return oktaAuthnResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, href, bytes.NewReader(body))
if err != nil {
return oktaAuthnResponse{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil {
return oktaAuthnResponse{}, fmt.Errorf("okta MFA verification request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
return oktaAuthnResponse{}, fmt.Errorf("okta rejected the MFA verification (status %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return oktaAuthnResponse{}, fmt.Errorf("okta MFA verification returned status %d", resp.StatusCode)
}

var verified oktaAuthnResponse
if err := json.NewDecoder(resp.Body).Decode(&verified); err != nil {
return oktaAuthnResponse{}, fmt.Errorf("failed to parse okta MFA verification response: %w", err)
}
return verified, nil
}

// oktaFetchSAMLAssertion loads the app embed link with the one-time session
// token and extracts the base64 SAML response from the auto-submit form.
func oktaFetchSAMLAssertion(ctx context.Context, client *http.Client, orgURL, appID, sessionToken string) (string, error) {
Expand Down
103 changes: 100 additions & 3 deletions internal/auth/okta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,110 @@ func TestResolveOktaSAMLSessionReusesCache(t *testing.T) {
}
}

func TestResolveOktaSAMLSessionRejectsMFARequired(t *testing.T) {
func TestResolveOktaSAMLSessionRejectsUnsupportedFactors(t *testing.T) {
server := newOktaTestServer(t, "MFA_REQUIRED")
stubOktaSeams(t)

_, err := ResolveOktaSAMLSession(context.Background(), oktaTestConfig(server.URL))
if err == nil || !strings.Contains(err.Error(), "MFA challenge") {
t.Fatalf("expected MFA-not-supported error, got %v", err)
if err == nil || !strings.Contains(err.Error(), "no supported okta MFA factor") {
t.Fatalf("expected unsupported-factor error, got %v", err)
}
}

func newOktaMFATestServer(t *testing.T, factorType string, verifyResponses []string) *httptest.Server {
t.Helper()
verifyCalls := 0
mux := http.NewServeMux()
var server *httptest.Server
mux.HandleFunc("/api/v1/authn", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{
"status": "MFA_REQUIRED",
"stateToken": "state-1",
"_embedded": {"factors": [{
"id": "factor-1",
"factorType": %q,
"provider": "OKTA",
"_links": {"verify": {"href": "%s/api/v1/authn/factors/factor-1/verify"}}
}]}
}`, factorType, server.URL)
})
mux.HandleFunc("/api/v1/authn/factors/factor-1/verify", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
idx := min(verifyCalls, len(verifyResponses)-1)
verifyCalls++
fmt.Fprint(w, verifyResponses[idx])
})
mux.HandleFunc("/home/amazon_aws/app123/272", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, `<html><input name="SAMLResponse" value="%s"/></html>`, testAssertionB64())
})
server = httptest.NewServer(mux)
t.Cleanup(server.Close)
return server
}

func TestResolveOktaSAMLSessionCompletesTOTPChallenge(t *testing.T) {
server := newOktaMFATestServer(t, "token:software:totp", []string{
`{"status": "SUCCESS", "sessionToken": "tok-123"}`,
})
stubOktaSeams(t)
origMFA := promptOktaMFACodeFn
t.Cleanup(func() { promptOktaMFACodeFn = origMFA })
promptOktaMFACodeFn = func(oktaFactor) (string, error) { return "654321", nil }

session, err := ResolveOktaSAMLSession(context.Background(), oktaTestConfig(server.URL))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if session.AccessKeyID != "AKIAOKTA" {
t.Fatalf("expected exchanged credentials after TOTP, got %+v", session)
}
}

func TestResolveOktaSAMLSessionPollsPushUntilApproved(t *testing.T) {
server := newOktaMFATestServer(t, "push", []string{
`{"status": "MFA_CHALLENGE", "factorResult": "WAITING"}`,
`{"status": "MFA_CHALLENGE", "factorResult": "WAITING"}`,
`{"status": "SUCCESS", "sessionToken": "tok-123"}`,
})
stubOktaSeams(t)
origInterval := oktaPushPollInterval
t.Cleanup(func() { oktaPushPollInterval = origInterval })
oktaPushPollInterval = time.Millisecond

session, err := ResolveOktaSAMLSession(context.Background(), oktaTestConfig(server.URL))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if session.AccessKeyID != "AKIAOKTA" {
t.Fatalf("expected exchanged credentials after push approval, got %+v", session)
}
}

func TestResolveOktaSAMLSessionFailsOnRejectedPush(t *testing.T) {
server := newOktaMFATestServer(t, "push", []string{
`{"status": "MFA_CHALLENGE", "factorResult": "REJECTED"}`,
})
stubOktaSeams(t)

_, err := ResolveOktaSAMLSession(context.Background(), oktaTestConfig(server.URL))
if err == nil || !strings.Contains(err.Error(), "rejected") {
t.Fatalf("expected push-rejected error, got %v", err)
}
}

func TestResolveOktaSAMLSessionFailsOnBadTOTPCode(t *testing.T) {
server := newOktaMFATestServer(t, "token:software:totp", []string{
`{"status": "MFA_CHALLENGE", "factorResult": "REJECTED"}`,
})
stubOktaSeams(t)
origMFA := promptOktaMFACodeFn
t.Cleanup(func() { promptOktaMFACodeFn = origMFA })
promptOktaMFACodeFn = func(oktaFactor) (string, error) { return "000000", nil }

_, err := ResolveOktaSAMLSession(context.Background(), oktaTestConfig(server.URL))
if err == nil || !strings.Contains(err.Error(), "MFA verification ended") {
t.Fatalf("expected TOTP verification failure, got %v", err)
}
}

Expand Down