Skip to content
Open
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 @@ -619,6 +619,7 @@ Managed auth connections (`kernel auth connections`). The commands below are new
- `kernel auth connections submit <id>` - New flags:
- `--field-value <id=value>` - Canonical field-id=value pair from the connection's `fields` list (repeatable); preferred over the legacy `--field`
- `--choice-id <id>` - Canonical choice ID from the connection's `choices` list
- `--interaction-id <id>` - Canonical interaction the submitted values answer. Only valid with `--field-value` or `--choice-id`; omit it and the CLI reads the connection's current interaction ID for you. Pass it to pin the submission, so the API rejects it if the flow has already moved on.

`kernel auth connections get` and `follow` list those IDs alongside the metadata the API captured for them, so you can tell the options apart before submitting. Fields show their type, ref, and any hint (which names the masked destination a one-time code was sent to); choices show their type, semantic MFA method (`sms`, `totp`, `push`, …), and masked destination.

Expand Down
100 changes: 75 additions & 25 deletions cmd/auth_connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ type AuthConnectionSubmitInput struct {
// canonical `field_values` keyed by the field IDs the API returned.
CanonicalFieldValues map[string]string
// SelectedChoiceID is the canonical choice ID from the API's `choices` list.
SelectedChoiceID string
SelectedChoiceID string
// InteractionID pins the submission to the canonical interaction the values
// were read from. Left empty, the CLI reads the connection's current
// interaction ID, since the API requires one for canonical submissions.
InteractionID string
MfaOptionID string
SignInOptionID string
SSOButtonSelector string
Expand Down Expand Up @@ -414,13 +418,13 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn
// models the one on `get` and the one on the `follow` event stream as two
// identical but distinct types, so both are converted to this before rendering.
type managedAuthInputField struct {
ID string
Label string
Type string
Ref string
Hint string
Required bool
ReplaceExisting bool
ID string
Label string
Type string
Ref string
Hint string
Reason string
Required bool
}

// managedAuthInputChoice is the choice counterpart of managedAuthInputField.
Expand Down Expand Up @@ -448,8 +452,8 @@ func formatManagedAuthField(f managedAuthInputField) string {
if f.Required {
meta = append(meta, "required")
}
if f.ReplaceExisting {
meta = append(meta, "replace-existing")
if f.Reason != "" {
meta = append(meta, "reason="+f.Reason)
}
if f.Hint != "" {
meta = append(meta, fmt.Sprintf("hint=%q", f.Hint))
Expand Down Expand Up @@ -538,17 +542,22 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e
// Canonical fields/choices supersede discovered_fields, mfa_options and
// pending_sso_buttons. Show them first so the IDs needed by `submit
// --field-value` and `submit --choice-id` are the first thing visible.
// The interaction ID scopes those submissions and only accompanies canonical
// input, so show it alongside them.
if auth.InteractionID != "" {
tableData = append(tableData, []string{"Interaction ID", auth.InteractionID})
}
if len(auth.Fields) > 0 {
fields := make([]string, 0, len(auth.Fields))
for _, f := range auth.Fields {
fields = append(fields, formatManagedAuthField(managedAuthInputField{
ID: f.ID,
Label: f.Label,
Type: f.Type,
Ref: f.Ref,
Hint: f.Hint,
Required: f.Required,
ReplaceExisting: f.ReplaceExisting,
ID: f.ID,
Label: f.Label,
Type: f.Type,
Ref: f.Ref,
Hint: f.Hint,
Reason: f.Reason,
Required: f.Required,
}))
}
tableData = append(tableData, []string{"Fields", strings.Join(fields, "; ")})
Expand Down Expand Up @@ -838,6 +847,28 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn
return fmt.Errorf("provide exactly one of: %s", submitModeFlags)
}

// The API binds canonical submissions to the interaction the values were read
// from, and rejects an interaction ID sent with a legacy submit mode.
isCanonical := hasCanonicalFields || hasChoice
if in.InteractionID != "" && !isCanonical {
return fmt.Errorf("the --interaction-id flag is only valid with --field-value or --choice-id")
}
if isCanonical && in.InteractionID == "" {
// Resolve the current interaction rather than making the user copy it out
// of `get` or `follow` first. The ID changes on every actionable pause, so
// the freshly read one is the only one worth defaulting to; passing
// --interaction-id explicitly pins the submission to an older interaction
// and lets the API reject it as stale.
conn, err := c.svc.Get(ctx, in.ID)
if err != nil {
return util.CleanedUpSdkError{Err: fmt.Errorf("failed to fetch connection for interaction ID resolution: %w", err)}
}
if conn == nil || conn.InteractionID == "" {
return fmt.Errorf("connection %s has no canonical interaction awaiting input; run 'kernel auth connections get %s' to see what the flow is waiting on", in.ID, in.ID)
}
in.InteractionID = conn.InteractionID
}

// Resolve MFA option: the user may pass the label (e.g. "Get a text"), the
// type (e.g. "sms"), or the display string ("Get a text (sms)"). The API
// expects the type, so look up the connection's available options and map
Expand Down Expand Up @@ -884,6 +915,9 @@ func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitIn
if hasChoice {
params.SubmitFieldsRequest.SelectedChoiceID = kernel.Opt(in.SelectedChoiceID)
}
if in.InteractionID != "" {
params.SubmitFieldsRequest.InteractionID = kernel.Opt(in.InteractionID)
}
if hasMfaOption {
params.SubmitFieldsRequest.MfaOptionID = kernel.Opt(in.MfaOptionID)
}
Expand Down Expand Up @@ -1063,17 +1097,20 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn
state.Timestamp.Local().Format(time.RFC3339),
state.FlowStatus,
state.FlowStep)
if state.InteractionID != "" {
pterm.Info.Printf(" Interaction ID: %s\n", state.InteractionID)
}
if len(state.Fields) > 0 {
fields := make([]string, 0, len(state.Fields))
for _, f := range state.Fields {
fields = append(fields, formatManagedAuthField(managedAuthInputField{
ID: f.ID,
Label: f.Label,
Type: f.Type,
Ref: f.Ref,
Hint: f.Hint,
Required: f.Required,
ReplaceExisting: f.ReplaceExisting,
ID: f.ID,
Label: f.Label,
Type: f.Type,
Ref: f.Ref,
Hint: f.Hint,
Reason: f.Reason,
Required: f.Required,
}))
}
pterm.Info.Printf(" Fields: %s\n", strings.Join(fields, ", "))
Expand Down Expand Up @@ -1181,8 +1218,18 @@ var authConnectionsSubmitCmd = &cobra.Command{
Short: "Submit field values to a login flow",
Long: `Submit field values for the login form. Poll the managed auth to track progress.

Canonical submissions (--field-value, --choice-id) are bound to the interaction
they answer. The CLI reads the connection's current interaction ID for you; pass
--interaction-id to pin the submission to a specific interaction instead.

Examples:
# Submit field values
# Submit canonical field values from the connection's fields list
kernel auth connections submit <id> --field-value field_email=me@example.com --field-value field_password=secret

# Answer a specific interaction (rejected if the flow has moved on)
kernel auth connections submit <id> --choice-id mfa_sms --interaction-id mai_abc123xyz

# Submit legacy field values
kernel auth connections submit <id> --field username=myuser --field password=mypass

# Select an MFA option
Expand Down Expand Up @@ -1291,6 +1338,7 @@ func init() {
addJSONOutputFlag(authConnectionsSubmitCmd)
authConnectionsSubmitCmd.Flags().StringArray("field-value", []string{}, "Canonical field-id=value pair from the connection's `fields` list (repeatable)")
authConnectionsSubmitCmd.Flags().String("choice-id", "", "Canonical choice ID from the connection's `choices` list")
authConnectionsSubmitCmd.Flags().String("interaction-id", "", "Canonical interaction ID the submitted values belong to; defaults to the connection's current interaction. Only valid with --field-value or --choice-id")
authConnectionsSubmitCmd.Flags().StringArray("field", []string{}, "Legacy field name=value pair (repeatable); prefer --field-value")
authConnectionsSubmitCmd.Flags().String("mfa-option-id", "", "MFA option ID if user selected an MFA method")
authConnectionsSubmitCmd.Flags().String("sign-in-option-id", "", "Sign-in option ID if the flow returned non-MFA choices")
Expand Down Expand Up @@ -1516,6 +1564,7 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error {
fieldPairs, _ := cmd.Flags().GetStringArray("field")
canonicalFieldPairs, _ := cmd.Flags().GetStringArray("field-value")
choiceID, _ := cmd.Flags().GetString("choice-id")
interactionID, _ := cmd.Flags().GetString("interaction-id")
mfaOptionID, _ := cmd.Flags().GetString("mfa-option-id")
signInOptionID, _ := cmd.Flags().GetString("sign-in-option-id")
ssoButtonSelector, _ := cmd.Flags().GetString("sso-button-selector")
Expand Down Expand Up @@ -1543,6 +1592,7 @@ func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error {
FieldValues: fieldValues,
CanonicalFieldValues: canonicalFieldValues,
SelectedChoiceID: choiceID,
InteractionID: interactionID,
MfaOptionID: mfaOptionID,
SignInOptionID: signInOptionID,
SSOButtonSelector: ssoButtonSelector,
Expand Down
132 changes: 122 additions & 10 deletions cmd/auth_connections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,17 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) {
Status: kernel.ManagedAuthStatusNeedsAuth,
FlowStatus: kernel.ManagedAuthFlowStatusInProgress,
FlowStep: kernel.ManagedAuthFlowStepAwaitingInput,
// Canonical fields and choices always arrive with the interaction
// they belong to, which `submit` needs.
InteractionID: "mai_abc123xyz",
Fields: []kernel.ManagedAuthField{
{
ID: "otp",
Label: "One-time code",
Type: "code",
Ref: "totp_code",
Hint: "Enter the code sent to +1 ••• ••• 1234",
Reason: "rejected",
Required: true,
},
},
Expand Down Expand Up @@ -181,8 +185,11 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) {
require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "e0x3vbw4z66kpwny3k5k46tj"}))

out := outBuf.String()
assert.Contains(t, out, `mai_abc123xyz`)
assert.Contains(t, out, `otp (One-time code)`)
assert.Contains(t, out, `code, ref=totp_code, required`)
// The reason tells the user why the field is being asked for: "rejected"
// means a stored credential was refused, so a new value has to replace it.
assert.Contains(t, out, `code, ref=totp_code, required, reason=rejected`)
assert.Contains(t, out, `hint="Enter the code sent to +1 ••• ••• 1234"`)
assert.Contains(t, out, `mfa_sms (Text message)`)
assert.Contains(t, out, `mfa_method, sms, to=+1 ••• ••• 1234`)
Expand Down Expand Up @@ -820,16 +827,24 @@ func TestLogin_TelemetryOverride(t *testing.T) {
assert.True(t, captured.Browser.Telemetry.Browser.Screenshot.Enabled.Value)
}

func TestSubmit_CanonicalChoiceID(t *testing.T) {
capturePtermOutput(t)
var captured kernel.AuthConnectionSubmitParams
fake := &FakeAuthConnectionService{
// canonicalSubmitFake serves the current interaction ID from `get` and captures
// what `submit` sends, which is what every canonical submission needs.
func canonicalSubmitFake(interactionID string, captured *kernel.AuthConnectionSubmitParams) *FakeAuthConnectionService {
return &FakeAuthConnectionService{
GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) {
return &kernel.ManagedAuth{ID: id, InteractionID: interactionID}, nil
},
SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) {
captured = body
*captured = body
return &kernel.SubmitFieldsResponse{Accepted: true}, nil
},
}
c := AuthConnectionCmd{svc: fake}
}

func TestSubmit_CanonicalChoiceID(t *testing.T) {
capturePtermOutput(t)
var captured kernel.AuthConnectionSubmitParams
c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)}
require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{
ID: "auth_1",
SelectedChoiceID: "choice_sms",
Expand All @@ -841,6 +856,53 @@ func TestSubmit_CanonicalChoiceID(t *testing.T) {
}

func TestSubmit_CanonicalFieldValues(t *testing.T) {
capturePtermOutput(t)
var captured kernel.AuthConnectionSubmitParams
c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)}
require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{
ID: "auth_1",
CanonicalFieldValues: map[string]string{"field_email": "me@example.com"},
}))
assert.Equal(t, map[string]string{"field_email": "me@example.com"}, captured.SubmitFieldsRequest.FieldValues)
assert.Nil(t, captured.SubmitFieldsRequest.Fields)
}

func TestSubmit_CanonicalResolvesCurrentInteractionID(t *testing.T) {
capturePtermOutput(t)
var captured kernel.AuthConnectionSubmitParams
c := AuthConnectionCmd{svc: canonicalSubmitFake("mai_current", &captured)}
require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{
ID: "auth_1",
CanonicalFieldValues: map[string]string{"field_email": "me@example.com"},
}))
require.True(t, captured.SubmitFieldsRequest.InteractionID.Valid())
assert.Equal(t, "mai_current", captured.SubmitFieldsRequest.InteractionID.Value)
}

func TestSubmit_ExplicitInteractionIDIsNotOverwritten(t *testing.T) {
capturePtermOutput(t)
var captured kernel.AuthConnectionSubmitParams
fake := canonicalSubmitFake("mai_current", &captured)
getCalls := 0
inner := fake.GetFunc
fake.GetFunc = func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) {
getCalls++
return inner(ctx, id, opts...)
}
c := AuthConnectionCmd{svc: fake}
require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{
ID: "auth_1",
SelectedChoiceID: "choice_sms",
// Pinning an older interaction is how a caller detects that the flow moved
// on, so the CLI must forward it untouched.
InteractionID: "mai_pinned",
}))
assert.Equal(t, 0, getCalls)
require.True(t, captured.SubmitFieldsRequest.InteractionID.Valid())
assert.Equal(t, "mai_pinned", captured.SubmitFieldsRequest.InteractionID.Value)
}

func TestSubmit_LegacyModeOmitsInteractionID(t *testing.T) {
capturePtermOutput(t)
var captured kernel.AuthConnectionSubmitParams
fake := &FakeAuthConnectionService{
Expand All @@ -851,11 +913,61 @@ func TestSubmit_CanonicalFieldValues(t *testing.T) {
}
c := AuthConnectionCmd{svc: fake}
require.NoError(t, c.Submit(context.Background(), AuthConnectionSubmitInput{
ID: "auth_1",
FieldValues: map[string]string{"username": "me"},
}))
// The API rejects an interaction ID paired with a legacy submit mode.
assert.False(t, captured.SubmitFieldsRequest.InteractionID.Valid())
}

func TestSubmit_InteractionIDRequiresCanonicalMode(t *testing.T) {
capturePtermOutput(t)
c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}}
err := c.Submit(context.Background(), AuthConnectionSubmitInput{
ID: "auth_1",
FieldValues: map[string]string{"username": "me"},
InteractionID: "mai_current",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "the --interaction-id flag is only valid with --field-value or --choice-id")
}

func TestSubmit_CanonicalWithoutPendingInteractionErrors(t *testing.T) {
capturePtermOutput(t)
submitted := false
fake := &FakeAuthConnectionService{
GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) {
return &kernel.ManagedAuth{ID: id}, nil
},
SubmitFunc: func(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (*kernel.SubmitFieldsResponse, error) {
submitted = true
return &kernel.SubmitFieldsResponse{Accepted: true}, nil
},
}
c := AuthConnectionCmd{svc: fake}
err := c.Submit(context.Background(), AuthConnectionSubmitInput{
ID: "auth_1",
SelectedChoiceID: "choice_sms",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "no canonical interaction awaiting input")
assert.False(t, submitted)
}

func TestSubmit_CanonicalGetErrorSurfaced(t *testing.T) {
capturePtermOutput(t)
fake := &FakeAuthConnectionService{
GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) {
return nil, errors.New("boom")
},
}
c := AuthConnectionCmd{svc: fake}
err := c.Submit(context.Background(), AuthConnectionSubmitInput{
ID: "auth_1",
CanonicalFieldValues: map[string]string{"field_email": "me@example.com"},
}))
assert.Equal(t, map[string]string{"field_email": "me@example.com"}, captured.SubmitFieldsRequest.FieldValues)
assert.Nil(t, captured.SubmitFieldsRequest.Fields)
})
require.Error(t, err)
assert.Contains(t, err.Error(), "interaction ID resolution")
}

func TestSubmit_CanonicalAndLegacyAreMutuallyExclusive(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/joho/godotenv v1.5.1
github.com/kernel/kernel-go-sdk v0.92.0
github.com/kernel/kernel-go-sdk v0.93.0
github.com/klauspost/compress v1.18.5
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pterm/pterm v0.12.80
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kernel/kernel-go-sdk v0.92.0 h1:3EeoPahTcGEo97BCbwT50gu8QJnawfL166z12hc8Ucg=
github.com/kernel/kernel-go-sdk v0.92.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
github.com/kernel/kernel-go-sdk v0.93.0 h1:mPsZKoQlLsgsC0TehWJ/Q5XqWwKu33bKfnuqnfNHtjs=
github.com/kernel/kernel-go-sdk v0.93.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
Expand Down
Loading