GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — feat(ca/acme/eab): add --json output to add, list, and remove by joshdrake · Pull Request #1663 · smallstep/cli

feat(ca/acme/eab): add --json output to add, list, and remove #1663

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

Sign up for GitHub

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
joshdrake wants to merge 1 commit into master
base: master
Loading
Loading
from feat/add-eab-json-output

Uh oh!

There was an error while loading. Please reload this page.

Draft

feat(ca/acme/eab): add --json output to add, list, and remove #1663

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
1 commit
Select commit
c4021be
feat(ca/acme/eab): add --json output to add, list, and remove
joshdrake Jul 7, 2026
File filter
Filter by extension

Clear filters
Conversations
Failed to load comments.
Loading

Uh oh!

There was an error while loading. Please reload this page.

Jump to
Jump to file
Failed to load files.
Loading

Uh oh!

There was an error while loading. Please reload this page.

Diff view
Diff view
feat(ca/acme/eab): add --json output to add, list, and remove
The `step ca acme eab` subcommands only emitted fixed-width column text,
making them awkward to script against (add.go even carried a standing
TODO asking for JSON output). Add a `--json` flag to each subcommand for
machine-readable output:

- add:    marshals the created key (including the secret) as a JSON object
- list:   collects all pages into a JSON array, bypassing $PAGER; the
          secret HMAC key is omitted, matching the table's masking
- remove: emits {"provisioner","id","deleted":true} on success

The shared cliEAK struct now has exported, JSON-tagged fields, and toCLI
guards createdAt with an IsZero check (mirroring boundAt) so unset
timestamps are dropped via omitempty. Adds the first unit tests for the
package covering toCLI and the JSON contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
  • Loading branch information

Uh oh!

There was an error while loading. Please reload this page.

@joshdrake @claude
joshdrake and claude committed Jul 7, 2026
commit c4021be3462e3a490b4680972e0b3e8c580e147e

There are no files selected for viewing

20 changes: 17 additions & 3 deletions 20 command/ca/acme/eab/add.go
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eab

import (
"encoding/json"
"fmt"
"os"

Expand All @@ -20,10 +21,11 @@ func addCommand() cli.Command {
Action: cli.ActionFunc(addAction),
Usage: "add ACME External Account Binding Key",
UsageText: `**step ca acme eab add** <provisioner> [<eab-key-reference>]
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--json**] [**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.AdminCert,
flags.AdminKey,
flags.AdminSubject,
Expand Down Expand Up @@ -53,6 +55,11 @@ $ step ca acme eab add my_acme_provisioner
Add an ACME External Account Binding Key with reference:
'''
$ step ca acme eab add my_acme_provisioner my_first_eab_key
'''

Add an ACME External Account Binding Key and output the result as JSON:
'''
$ step ca acme eab add my_acme_provisioner my_first_eab_key --json
'''`,
}
}
Expand Down Expand Up @@ -84,12 +91,19 @@ func addAction(ctx *cli.Context) (err error) {

cliEAK := toCLI(ctx, client, eak)

// TODO(hs): JSON output, so that executing this command can be more easily automated?
if ctx.Bool("json") {
b, err := json.MarshalIndent(cliEAK, "", " ")
if err != nil {
return errors.Wrap(err, "error marshaling ACME EAB key")
}
fmt.Println(string(b))
return nil
}

out := os.Stdout
format := "%-36s%-28s%-48s%s\n"
fmt.Fprintf(out, format, "Key ID", "Provisioner", "Key (base64, raw url encoded)", "Reference")
fmt.Fprintf(out, format, cliEAK.id, cliEAK.provisioner, cliEAK.key, cliEAK.reference)
fmt.Fprintf(out, format, cliEAK.ID, cliEAK.Provisioner, cliEAK.Key, cliEAK.Reference)

return nil
}
38 changes: 24 additions & 14 deletions 38 command/ca/acme/eab/eab.go
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,41 @@ import (
)

type cliEAK struct {
id string
provisioner string
reference string
key string
createdAt string
boundAt string
account string
ID string `json:"id"`
Provisioner string `json:"provisioner"`
Reference string `json:"reference"`
Key string `json:"key,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
BoundAt string `json:"boundAt,omitempty"`
Account string `json:"account,omitempty"`
}

func toCLI(_ *cli.Context, _ *ca.AdminClient, eak *linkedca.EABKey) *cliEAK {
createdAt := ""
if !eak.CreatedAt.AsTime().IsZero() {
createdAt = eak.CreatedAt.AsTime().Format("2006-01-02 15:04:05 -07:00")
}
boundAt := ""
if !eak.BoundAt.AsTime().IsZero() {
boundAt = eak.BoundAt.AsTime().Format("2006-01-02 15:04:05 -07:00")
}
return &cliEAK{
id: eak.Id,
provisioner: eak.Provisioner,
reference: eak.Reference,
key: base64.RawURLEncoding.Strict().EncodeToString(eak.HmacKey),
createdAt: eak.CreatedAt.AsTime().Format("2006-01-02 15:04:05 -07:00"),
boundAt: boundAt,
account: eak.Account,
ID: eak.Id,
Provisioner: eak.Provisioner,
Reference: eak.Reference,
Key: base64.RawURLEncoding.Strict().EncodeToString(eak.HmacKey),
CreatedAt: createdAt,
BoundAt: boundAt,
Account: eak.Account,
}
}

// jsonFlag toggles machine-readable JSON output for the eab subcommands.
var jsonFlag = cli.BoolFlag{
Name: "json",
Usage: `Print output as a JSON object (or array) instead of the human-readable table.`,
}

// Command returns the eab subcommand.
func Command() cli.Command {
return cli.Command{
Expand Down
100 changes: 100 additions & 0 deletions 100 command/ca/acme/eab/eab_test.go
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package eab

import (
"encoding/json"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"

"github.com/smallstep/linkedca"
)

func TestToCLI(t *testing.T) {
created := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
bound := time.Date(2026, 7, 6, 13, 30, 0, 0, time.UTC)

t.Run("bound key", func(t *testing.T) {
eak := &linkedca.EABKey{
Id: "keyid",
Provisioner: "my_acme",
Reference: "my_ref",
HmacKey: []byte("secret-hmac-key"),
Account: "acct-1",
CreatedAt: timestamppb.New(created),
BoundAt: timestamppb.New(bound),
}

got := toCLI(nil, nil, eak)
assert.Equal(t, "keyid", got.ID)
assert.Equal(t, "my_acme", got.Provisioner)
assert.Equal(t, "my_ref", got.Reference)
assert.Equal(t, "c2VjcmV0LWhtYWMta2V5", got.Key) // base64 raw-url of the hmac key
assert.Equal(t, "acct-1", got.Account)
assert.Equal(t, "2026-07-06 12:00:00 +00:00", got.CreatedAt)
assert.Equal(t, "2026-07-06 13:30:00 +00:00", got.BoundAt)
})

t.Run("unbound key with zero timestamps", func(t *testing.T) {
eak := &linkedca.EABKey{
Id: "keyid",
Provisioner: "my_acme",
CreatedAt: timestamppb.New(time.Time{}),
BoundAt: timestamppb.New(time.Time{}),
}

got := toCLI(nil, nil, eak)
// zero timestamps are represented as empty strings so they are dropped
// from JSON via omitempty.
assert.Empty(t, got.CreatedAt)
assert.Empty(t, got.BoundAt)
assert.Empty(t, got.Account)
})
}

func TestCLIEAK_JSON(t *testing.T) {
t.Run("add output includes key and omits empty fields", func(t *testing.T) {
eak := &cliEAK{
ID: "keyid",
Provisioner: "my_acme",
Reference: "my_ref",
Key: "c2VjcmV0",
}

b, err := json.Marshal(eak)
require.NoError(t, err)

var m map[string]any
require.NoError(t, json.Unmarshal(b, &m))
assert.Equal(t, "keyid", m["id"])
assert.Equal(t, "my_acme", m["provisioner"])
assert.Equal(t, "my_ref", m["reference"])
assert.Equal(t, "c2VjcmV0", m["key"])
assert.NotContains(t, m, "createdAt")
assert.NotContains(t, m, "boundAt")
assert.NotContains(t, m, "account")
})

t.Run("list output omits the secret key", func(t *testing.T) {
eak := &cliEAK{
ID: "keyid",
Provisioner: "my_acme",
Reference: "my_ref",
Key: "", // cleared for list output
CreatedAt: "2026-07-06 12:00:00 +00:00",
Account: "acct-1",
}

b, err := json.Marshal(eak)
require.NoError(t, err)

var m map[string]any
require.NoError(t, json.Unmarshal(b, &m))
assert.NotContains(t, m, "key")
assert.Equal(t, "2026-07-06 12:00:00 +00:00", m["createdAt"])
assert.Equal(t, "acct-1", m["account"])
assert.NotContains(t, m, "boundAt")
})
}
52 changes: 44 additions & 8 deletions 52 command/ca/acme/eab/list.go
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eab

import (
"encoding/json"
"fmt"
"io"
"os"
Expand All @@ -24,11 +25,12 @@ func listCommand() cli.Command {
Action: cli.ActionFunc(listAction),
Usage: "list all ACME External Account Binding Keys",
UsageText: `**step ca acme eab list** <provisioner> [<eab-key-reference>]
[**--limit**=<number>]
[**--json**] [**--limit**=<number>]
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.Limit,
flags.NoPager,
flags.AdminCert,
Expand Down Expand Up @@ -64,6 +66,11 @@ Show ACME External Account Binding Key with specific reference:
'''
$ step ca acme eab list my_acme_provisioner my_reference
'''

List all ACME External Account Binding Keys as a JSON array (keys are omitted):
'''
$ step ca acme eab list my_acme_provisioner --json
'''
`,
}
}
Expand All @@ -86,6 +93,41 @@ func listAction(ctx *cli.Context) (err error) {
return errors.Wrap(err, "error creating admin client")
}

// default to API paging per 100 entities
limit := uint(0)
if ctx.IsSet("limit") {
limit = ctx.Uint("limit")
}

// JSON output collects every page into a single array and bypasses the
// $PAGER machinery, which only makes sense for the human-readable table.
if ctx.Bool("json") {
eaks := []*cliEAK{}
cursor := ""
for {
options := []ca.AdminOption{ca.WithAdminCursor(cursor), ca.WithAdminLimit(cast.Int(limit))}
eaksResponse, err := client.GetExternalAccountKeysPaginate(provisioner, reference, options...)
if err != nil {
return errors.Wrap(notImplemented(err), "error retrieving ACME EAB keys")
}
for _, k := range eaksResponse.EAKs {
cliEAK := toCLI(ctx, client, k)
cliEAK.Key = "" // never expose the secret HMAC key in list output
eaks = append(eaks, cliEAK)
}
if eaksResponse.NextCursor == "" {
break
}
cursor = eaksResponse.NextCursor
}
b, err := json.MarshalIndent(eaks, "", " ")
if err != nil {
return errors.Wrap(err, "error marshaling ACME EAB keys")
}
fmt.Println(string(b))
return nil
}

var out io.WriteCloser
var cmd *exec.Cmd

Expand Down Expand Up @@ -121,12 +163,6 @@ func listAction(ctx *cli.Context) (err error) {
out = os.Stdout
}

// default to API paging per 100 entities
limit := uint(0)
if ctx.IsSet("limit") {
limit = ctx.Uint("limit")
}

cursor := ""
format := "%-36s%-28s%-16s%-30s%-30s%-40s%s\n"
firstIteration := true
Expand Down Expand Up @@ -154,7 +190,7 @@ func listAction(ctx *cli.Context) (err error) {
}
for _, k := range eaksResponse.EAKs {
cliEAK := toCLI(ctx, client, k)
_, err = fmt.Fprintf(out, format, cliEAK.id, cliEAK.provisioner, "*****", cliEAK.createdAt, cliEAK.boundAt, cliEAK.account, cliEAK.reference)
_, err = fmt.Fprintf(out, format, cliEAK.ID, cliEAK.Provisioner, "*****", cliEAK.CreatedAt, cliEAK.BoundAt, cliEAK.Account, cliEAK.Reference)
if err != nil {
return errors.Wrap(err, "error writing ACME EAB key to output")
}
Expand Down
26 changes: 25 additions & 1 deletion 26 command/ca/acme/eab/remove.go
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eab

import (
"encoding/json"
"fmt"

"github.com/pkg/errors"
Expand All @@ -18,10 +19,11 @@ func removeCommand() cli.Command {
Action: cli.ActionFunc(removeAction),
Usage: "remove an ACME EAB Key from the CA",
UsageText: `**step ca acme eab remove** <provisioner> <eab-key-id>
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--json**] [**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.AdminCert,
flags.AdminKey,
flags.AdminSubject,
Expand All @@ -47,6 +49,11 @@ Remove ACME EAB Key with Key ID "zFGdKC1sHmNf3Wsx3OujY808chxwEdmr" from my_acme_
'''
$ step ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr
'''

Remove an ACME EAB Key and output the result as JSON:
'''
$ step ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr --json
'''
`,
}
}
Expand All @@ -70,6 +77,23 @@ func removeAction(ctx *cli.Context) error {
return errors.Wrap(notImplemented(err), "error removing ACME EAB key")
}

if ctx.Bool("json") {
b, err := json.MarshalIndent(struct {
Provisioner string `json:"provisioner"`
ID string `json:"id"`
Deleted bool `json:"deleted"`
}{
Provisioner: provisioner,
ID: keyID,
Deleted: true,
}, "", " ")
if err != nil {
return errors.Wrap(err, "error marshaling ACME EAB key")
}
fmt.Println(string(b))
return nil
}

fmt.Println("Key was deleted successfully!")

return nil
Expand Down
Add this suggestion to a batch that can be applied as a single commit. This suggestion is invalid because no changes were made to the code. Suggestions cannot be applied while the pull request is closed. Suggestions cannot be applied while viewing a subset of changes. Only one suggestion per line can be applied in a batch. Add this suggestion to a batch that can be applied as a single commit. Applying suggestions on deleted lines is not supported. You must change the existing code in this line in order to create a valid suggestion. Outdated suggestions cannot be applied. This suggestion has been applied or marked resolved. Suggestions cannot be applied from pending reviews. Suggestions cannot be applied on multi-line comments. Suggestions cannot be applied while the pull request is queued to merge. Suggestion cannot be applied right now. Please check back later.
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.