feat(cli): add ermite web passkey list and remove
- commit
- 26943f5f8868608bcc594a695c03a0b79df4f916
- author
- brice · · 2026-10-12 15:16 UTC
- parent
- 0ab5a9d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +263 -3
M internal/app/cli_fake_test.go +12 -0
| @@ -29,6 +29,7 @@ type fakeAPI struct { | |||
| 29 | 29 | ermiteconnect.UnimplementedServerServiceHandler | |
| 30 | 30 | ermiteconnect.UnimplementedEventServiceHandler | |
| 31 | 31 | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 32 | + | ermiteconnect.UnimplementedSessionServiceHandler | |
| 32 | 33 | ||
| 33 | 34 | mu sync.Mutex | |
| 34 | 35 | calls []string // procedure names, in order | |
| @@ -55,6 +56,9 @@ type fakeAPI struct { | |||
| 55 | 56 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | |
| 56 | 57 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | |
| 57 | 58 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | |
| 59 | + | ||
| 60 | + | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | |
| 61 | + | removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) | |
| 58 | 62 | } | |
| 59 | 63 | ||
| 60 | 64 | func (f *fakeAPI) record(name string, h http.Header, req any) { | |
| @@ -137,6 +141,12 @@ func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkRead | |||
| 137 | 141 | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | |
| 138 | 142 | return respond(f, "GetSigningKey", r, f.getSigningKey) | |
| 139 | 143 | } | |
| 144 | + | func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) { | |
| 145 | + | return respond(f, "ListPasskeys", r, f.listPasskeys) | |
| 146 | + | } | |
| 147 | + | func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) { | |
| 148 | + | return respond(f, "RemovePasskey", r, f.removePasskey) | |
| 149 | + | } | |
| 140 | 150 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | |
| 141 | 151 | f.record("Watch", r.Header(), r.Msg) | |
| 142 | 152 | if f.watch == nil { | |
| @@ -160,6 +170,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 160 | 170 | mount(p, h) | |
| 161 | 171 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | |
| 162 | 172 | mount(p, h) | |
| 173 | + | p, h = ermiteconnect.NewSessionServiceHandler(f) | |
| 174 | + | mount(p, h) | |
| 163 | 175 | srv := httptest.NewServer(mux) | |
| 164 | 176 | t.Cleanup(srv.Close) | |
| 165 | 177 | return srv.URL | |
| @@ -29,6 +29,7 @@ type fakeAPI struct { | |||||
| 29 | ermiteconnect.UnimplementedServerServiceHandler | 29 | ermiteconnect.UnimplementedServerServiceHandler | ||
| 30 | ermiteconnect.UnimplementedEventServiceHandler | 30 | ermiteconnect.UnimplementedEventServiceHandler | ||
| 31 | ermiteconnect.UnimplementedMergeRequestServiceHandler | 31 | ermiteconnect.UnimplementedMergeRequestServiceHandler | ||
| 32 | + | ermiteconnect.UnimplementedSessionServiceHandler | |||
| 32 | 33 | ||||
| 33 | mu sync.Mutex | 34 | mu sync.Mutex | ||
| 34 | calls []string // procedure names, in order | 35 | calls []string // procedure names, in order | ||
| @@ -55,6 +56,9 @@ type fakeAPI struct { | |||||
| 55 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | 56 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | ||
| 56 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | 57 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | ||
| 57 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | 58 | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | ||
| 59 | + | ||||
| 60 | + | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | |||
| 61 | + | removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) | |||
| 58 | } | 62 | } | ||
| 59 | 63 | ||||
| 60 | func (f *fakeAPI) record(name string, h http.Header, req any) { | 64 | func (f *fakeAPI) record(name string, h http.Header, req any) { | ||
| @@ -137,6 +141,12 @@ func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkRead | |||||
| 137 | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | 141 | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | ||
| 138 | return respond(f, "GetSigningKey", r, f.getSigningKey) | 142 | return respond(f, "GetSigningKey", r, f.getSigningKey) | ||
| 139 | } | 143 | } | ||
| 144 | + | func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) { | |||
| 145 | + | return respond(f, "ListPasskeys", r, f.listPasskeys) | |||
| 146 | + | } | |||
| 147 | + | func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) { | |||
| 148 | + | return respond(f, "RemovePasskey", r, f.removePasskey) | |||
| 149 | + | } | |||
| 140 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | 150 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | ||
| 141 | f.record("Watch", r.Header(), r.Msg) | 151 | f.record("Watch", r.Header(), r.Msg) | ||
| 142 | if f.watch == nil { | 152 | if f.watch == nil { | ||
| @@ -160,6 +170,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||||
| 160 | mount(p, h) | 170 | mount(p, h) | ||
| 161 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | 171 | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | ||
| 162 | mount(p, h) | 172 | mount(p, h) | ||
| 173 | + | p, h = ermiteconnect.NewSessionServiceHandler(f) | |||
| 174 | + | mount(p, h) | |||
| 163 | srv := httptest.NewServer(mux) | 175 | srv := httptest.NewServer(mux) | ||
| 164 | t.Cleanup(srv.Close) | 176 | t.Cleanup(srv.Close) | ||
| 165 | return srv.URL | 177 | return srv.URL | ||
M internal/app/cli_web.go +117 -3
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | ||
| @@ -10,6 +11,7 @@ import ( | |||
| 10 | 11 | "github.com/spf13/cobra" | |
| 11 | 12 | ||
| 12 | 13 | "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 17 | // jsonLoginLink is the stable JSON shape of `ermite web login --json`. | |
| @@ -22,10 +24,10 @@ func newWebCmd(env *cliEnv) *cobra.Command { | |||
| 22 | 24 | web := &cobra.Command{ | |
| 23 | 25 | Use: "web", | |
| 24 | 26 | Short: "Sign the owner in to the web interface of a remote server", | |
| 25 | - | Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" + | |
| 26 | - | "token that holds every scope and is not limited to repositories.", | |
| 27 | + | Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" + | |
| 28 | + | "token, or a token that holds every scope and is not limited to repositories.", | |
| 27 | 29 | } | |
| 28 | - | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env)) | |
| 30 | + | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env)) | |
| 29 | 31 | return web | |
| 30 | 32 | } | |
| 31 | 33 | ||
| @@ -87,3 +89,115 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command { | |||
| 87 | 89 | } | |
| 88 | 90 | return cmd | |
| 89 | 91 | } | |
| 92 | + | ||
| 93 | + | // jsonPasskey is the stable JSON shape of a passkey. It never holds key | |
| 94 | + | // material. | |
| 95 | + | type jsonPasskey struct { | |
| 96 | + | ID string `json:"id"` | |
| 97 | + | Name string `json:"name"` | |
| 98 | + | CreatedAt *string `json:"created_at"` | |
| 99 | + | LastUsedAt *string `json:"last_used_at"` | |
| 100 | + | Synced bool `json:"synced"` | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | const jsonPasskeyHelp = "\n\nJSON fields (--json): id (credential id, base64url), name, created_at (RFC 3339 UTC), last_used_at\n" + | |
| 104 | + | "(null if never used), synced (the authenticator reports the passkey as backed up)." | |
| 105 | + | ||
| 106 | + | func newWebPasskeyCmd(env *cliEnv) *cobra.Command { | |
| 107 | + | cmd := &cobra.Command{ | |
| 108 | + | Use: "passkey", | |
| 109 | + | Short: "List or remove the passkeys that sign the owner in", | |
| 110 | + | Long: "List or remove the passkeys that sign the owner in to the web interface. Adding one needs a browser:\n" + | |
| 111 | + | "sign in (`ermite web login`), then open /-/passkeys. Passkeys are only available when the server's\n" + | |
| 112 | + | "public URL is https (or localhost) with a host name, not an IP address.", | |
| 113 | + | } | |
| 114 | + | cmd.AddCommand(newWebPasskeyListCmd(env), newWebPasskeyRemoveCmd(env)) | |
| 115 | + | return cmd | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func listPasskeys(ctx context.Context, c *client.Client) ([]*ermite.Passkey, error) { | |
| 119 | + | res, err := c.Sessions().ListPasskeys(ctx, connect.NewRequest(&ermite.ListPasskeysRequest{})) | |
| 120 | + | if err != nil { | |
| 121 | + | return nil, c.Describe(err) | |
| 122 | + | } | |
| 123 | + | return res.Msg.GetPasskeys(), nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func newWebPasskeyListCmd(env *cliEnv) *cobra.Command { | |
| 127 | + | cmd := &cobra.Command{ | |
| 128 | + | Use: "list", | |
| 129 | + | Short: "List the registered passkeys", | |
| 130 | + | Long: "List the passkeys, oldest first, with the id to give to `ermite web passkey remove`." + jsonPasskeyHelp + | |
| 131 | + | "\n--json prints an array of passkeys.", | |
| 132 | + | Args: cobra.NoArgs, | |
| 133 | + | } | |
| 134 | + | asJSON := addJSONFlag(cmd) | |
| 135 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 136 | + | c, err := env.client(cmd, true) | |
| 137 | + | if err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | passkeys, err := listPasskeys(env.ctx(cmd), c) | |
| 141 | + | if err != nil { | |
| 142 | + | return err | |
| 143 | + | } | |
| 144 | + | if *asJSON { | |
| 145 | + | out := make([]jsonPasskey, 0, len(passkeys)) | |
| 146 | + | for _, p := range passkeys { | |
| 147 | + | out = append(out, jsonPasskey{ | |
| 148 | + | ID: p.GetId(), Name: p.GetName(), CreatedAt: jsonTime(p.GetCreatedAt()), | |
| 149 | + | LastUsedAt: jsonTime(p.GetLastUsedAt()), Synced: p.GetSynced(), | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 153 | + | } | |
| 154 | + | if len(passkeys) == 0 { | |
| 155 | + | noticef(cmd, "no passkeys") | |
| 156 | + | return nil | |
| 157 | + | } | |
| 158 | + | rows := make([][]string, 0, len(passkeys)) | |
| 159 | + | for _, p := range passkeys { | |
| 160 | + | synced := "no" | |
| 161 | + | if p.GetSynced() { | |
| 162 | + | synced = "yes" | |
| 163 | + | } | |
| 164 | + | rows = append(rows, []string{ | |
| 165 | + | p.GetId(), p.GetName(), shortTime(p.GetCreatedAt(), "-"), shortTime(p.GetLastUsedAt(), "never"), synced, | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | return table(cmd.OutOrStdout(), []string{"ID", "NAME", "CREATED", "LAST USED", "SYNCED"}, rows) | |
| 169 | + | } | |
| 170 | + | return cmd | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command { | |
| 174 | + | return &cobra.Command{ | |
| 175 | + | Use: "remove <id>", | |
| 176 | + | Short: "Remove a passkey and sign every browser out", | |
| 177 | + | Long: "Remove a passkey by id (see `ermite web passkey list`). It can no longer sign in. Removing one also\n" + | |
| 178 | + | "signs every browser out, like `ermite web logout --all`: web sessions are not tied to the passkey that\n" + | |
| 179 | + | "opened them. Sign in again with another passkey or `ermite web login`.", | |
| 180 | + | Args: cobra.ExactArgs(1), | |
| 181 | + | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { | |
| 182 | + | if len(args) > 0 { | |
| 183 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 184 | + | } | |
| 185 | + | ids := complete(env, cmd, toComplete, listPasskeys, func(p *ermite.Passkey) (string, string) { | |
| 186 | + | return p.GetId(), clean(p.GetName()) | |
| 187 | + | }) | |
| 188 | + | return ids, cobra.ShellCompDirectiveNoFileComp | |
| 189 | + | }, | |
| 190 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 191 | + | c, err := env.client(cmd, true) | |
| 192 | + | if err != nil { | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | if _, err := c.Sessions().RemovePasskey(env.ctx(cmd), connect.NewRequest(&ermite.RemovePasskeyRequest{Id: args[0]})); err != nil { | |
| 196 | + | return c.Describe(err) | |
| 197 | + | } | |
| 198 | + | noticef(cmd, "passkey removed; every browser is signed out") | |
| 199 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", args[0]) | |
| 200 | + | return err | |
| 201 | + | }, | |
| 202 | + | } | |
| 203 | + | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | 9 | ||||
| @@ -10,6 +11,7 @@ import ( | |||||
| 10 | "github.com/spf13/cobra" | 11 | "github.com/spf13/cobra" | ||
| 11 | 12 | ||||
| 12 | "bnema.dev/ermite-api/gen/ermite" | 13 | "bnema.dev/ermite-api/gen/ermite" | ||
| 14 | + | "bnema.dev/ermite/internal/adapters/client" | |||
| 13 | ) | 15 | ) | ||
| 14 | 16 | ||||
| 15 | // jsonLoginLink is the stable JSON shape of `ermite web login --json`. | 17 | // jsonLoginLink is the stable JSON shape of `ermite web login --json`. | ||
| @@ -22,10 +24,10 @@ func newWebCmd(env *cliEnv) *cobra.Command { | |||||
| 22 | web := &cobra.Command{ | 24 | web := &cobra.Command{ | ||
| 23 | Use: "web", | 25 | Use: "web", | ||
| 24 | Short: "Sign the owner in to the web interface of a remote server", | 26 | Short: "Sign the owner in to the web interface of a remote server", | ||
| 25 | - | Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" + | 27 | + | Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" + |
| 26 | - | "token that holds every scope and is not limited to repositories.", | 28 | + | "token, or a token that holds every scope and is not limited to repositories.", |
| 27 | } | 29 | } | ||
| 28 | - | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env)) | 30 | + | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env)) |
| 29 | return web | 31 | return web | ||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| @@ -87,3 +89,115 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command { | |||||
| 87 | } | 89 | } | ||
| 88 | return cmd | 90 | return cmd | ||
| 89 | } | 91 | } | ||
| 92 | + | ||||
| 93 | + | // jsonPasskey is the stable JSON shape of a passkey. It never holds key | |||
| 94 | + | // material. | |||
| 95 | + | type jsonPasskey struct { | |||
| 96 | + | ID string `json:"id"` | |||
| 97 | + | Name string `json:"name"` | |||
| 98 | + | CreatedAt *string `json:"created_at"` | |||
| 99 | + | LastUsedAt *string `json:"last_used_at"` | |||
| 100 | + | Synced bool `json:"synced"` | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | const jsonPasskeyHelp = "\n\nJSON fields (--json): id (credential id, base64url), name, created_at (RFC 3339 UTC), last_used_at\n" + | |||
| 104 | + | "(null if never used), synced (the authenticator reports the passkey as backed up)." | |||
| 105 | + | ||||
| 106 | + | func newWebPasskeyCmd(env *cliEnv) *cobra.Command { | |||
| 107 | + | cmd := &cobra.Command{ | |||
| 108 | + | Use: "passkey", | |||
| 109 | + | Short: "List or remove the passkeys that sign the owner in", | |||
| 110 | + | Long: "List or remove the passkeys that sign the owner in to the web interface. Adding one needs a browser:\n" + | |||
| 111 | + | "sign in (`ermite web login`), then open /-/passkeys. Passkeys are only available when the server's\n" + | |||
| 112 | + | "public URL is https (or localhost) with a host name, not an IP address.", | |||
| 113 | + | } | |||
| 114 | + | cmd.AddCommand(newWebPasskeyListCmd(env), newWebPasskeyRemoveCmd(env)) | |||
| 115 | + | return cmd | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func listPasskeys(ctx context.Context, c *client.Client) ([]*ermite.Passkey, error) { | |||
| 119 | + | res, err := c.Sessions().ListPasskeys(ctx, connect.NewRequest(&ermite.ListPasskeysRequest{})) | |||
| 120 | + | if err != nil { | |||
| 121 | + | return nil, c.Describe(err) | |||
| 122 | + | } | |||
| 123 | + | return res.Msg.GetPasskeys(), nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func newWebPasskeyListCmd(env *cliEnv) *cobra.Command { | |||
| 127 | + | cmd := &cobra.Command{ | |||
| 128 | + | Use: "list", | |||
| 129 | + | Short: "List the registered passkeys", | |||
| 130 | + | Long: "List the passkeys, oldest first, with the id to give to `ermite web passkey remove`." + jsonPasskeyHelp + | |||
| 131 | + | "\n--json prints an array of passkeys.", | |||
| 132 | + | Args: cobra.NoArgs, | |||
| 133 | + | } | |||
| 134 | + | asJSON := addJSONFlag(cmd) | |||
| 135 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |||
| 136 | + | c, err := env.client(cmd, true) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | passkeys, err := listPasskeys(env.ctx(cmd), c) | |||
| 141 | + | if err != nil { | |||
| 142 | + | return err | |||
| 143 | + | } | |||
| 144 | + | if *asJSON { | |||
| 145 | + | out := make([]jsonPasskey, 0, len(passkeys)) | |||
| 146 | + | for _, p := range passkeys { | |||
| 147 | + | out = append(out, jsonPasskey{ | |||
| 148 | + | ID: p.GetId(), Name: p.GetName(), CreatedAt: jsonTime(p.GetCreatedAt()), | |||
| 149 | + | LastUsedAt: jsonTime(p.GetLastUsedAt()), Synced: p.GetSynced(), | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | return writeJSON(cmd.OutOrStdout(), out) | |||
| 153 | + | } | |||
| 154 | + | if len(passkeys) == 0 { | |||
| 155 | + | noticef(cmd, "no passkeys") | |||
| 156 | + | return nil | |||
| 157 | + | } | |||
| 158 | + | rows := make([][]string, 0, len(passkeys)) | |||
| 159 | + | for _, p := range passkeys { | |||
| 160 | + | synced := "no" | |||
| 161 | + | if p.GetSynced() { | |||
| 162 | + | synced = "yes" | |||
| 163 | + | } | |||
| 164 | + | rows = append(rows, []string{ | |||
| 165 | + | p.GetId(), p.GetName(), shortTime(p.GetCreatedAt(), "-"), shortTime(p.GetLastUsedAt(), "never"), synced, | |||
| 166 | + | }) | |||
| 167 | + | } | |||
| 168 | + | return table(cmd.OutOrStdout(), []string{"ID", "NAME", "CREATED", "LAST USED", "SYNCED"}, rows) | |||
| 169 | + | } | |||
| 170 | + | return cmd | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command { | |||
| 174 | + | return &cobra.Command{ | |||
| 175 | + | Use: "remove <id>", | |||
| 176 | + | Short: "Remove a passkey and sign every browser out", | |||
| 177 | + | Long: "Remove a passkey by id (see `ermite web passkey list`). It can no longer sign in. Removing one also\n" + | |||
| 178 | + | "signs every browser out, like `ermite web logout --all`: web sessions are not tied to the passkey that\n" + | |||
| 179 | + | "opened them. Sign in again with another passkey or `ermite web login`.", | |||
| 180 | + | Args: cobra.ExactArgs(1), | |||
| 181 | + | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { | |||
| 182 | + | if len(args) > 0 { | |||
| 183 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 184 | + | } | |||
| 185 | + | ids := complete(env, cmd, toComplete, listPasskeys, func(p *ermite.Passkey) (string, string) { | |||
| 186 | + | return p.GetId(), clean(p.GetName()) | |||
| 187 | + | }) | |||
| 188 | + | return ids, cobra.ShellCompDirectiveNoFileComp | |||
| 189 | + | }, | |||
| 190 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 191 | + | c, err := env.client(cmd, true) | |||
| 192 | + | if err != nil { | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 195 | + | if _, err := c.Sessions().RemovePasskey(env.ctx(cmd), connect.NewRequest(&ermite.RemovePasskeyRequest{Id: args[0]})); err != nil { | |||
| 196 | + | return c.Describe(err) | |||
| 197 | + | } | |||
| 198 | + | noticef(cmd, "passkey removed; every browser is signed out") | |||
| 199 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", args[0]) | |||
| 200 | + | return err | |||
| 201 | + | }, | |||
| 202 | + | } | |||
| 203 | + | } | |||
A internal/app/cli_web_test.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func twoPasskeys() *ermite.ListPasskeysResponse { | |
| 17 | + | return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{ | |
| 18 | + | {Id: "AQID", Name: "Pixel 9", CreatedAt: ts("2026-10-01T08:00:00Z"), LastUsedAt: ts("2026-10-09T18:30:00Z"), Synced: true}, | |
| 19 | + | {Id: "BAUG", Name: "yubikey", CreatedAt: ts("2026-10-02T09:15:00Z")}, | |
| 20 | + | }} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestWebPasskeyList(t *testing.T) { | |
| 24 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |
| 25 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t) | |
| 26 | + | assert.Empty(t, res.stderr) | |
| 27 | + | assert.Regexp(t, `(?m)^ID\s+NAME\s+CREATED\s+LAST USED\s+SYNCED$`, res.stdout) | |
| 28 | + | assert.Regexp(t, `(?m)^AQID\s+Pixel 9\s+2026-10-01 08:00\s+2026-10-09 18:30\s+yes$`, res.stdout) | |
| 29 | + | assert.Regexp(t, `(?m)^BAUG\s+yubikey\s+2026-10-02 09:15\s+never\s+no$`, res.stdout) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestWebPasskeyListEmpty(t *testing.T) { | |
| 33 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |
| 34 | + | return &ermite.ListPasskeysResponse{}, nil | |
| 35 | + | }} | |
| 36 | + | url := f.serve(t) | |
| 37 | + | res := runCLI(t, envFor(url), "", "web", "passkey", "list").ok(t) | |
| 38 | + | assert.Empty(t, res.stdout) | |
| 39 | + | assert.Contains(t, res.stderr, "no passkeys") | |
| 40 | + | ||
| 41 | + | res = runCLI(t, envFor(url), "", "web", "passkey", "list", "--json").ok(t) | |
| 42 | + | assert.JSONEq(t, `[]`, res.stdout) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestWebPasskeyListJSON(t *testing.T) { | |
| 46 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |
| 47 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list", "--json").ok(t) | |
| 48 | + | assert.Empty(t, res.stderr) | |
| 49 | + | assert.JSONEq(t, `[ | |
| 50 | + | {"id":"AQID","name":"Pixel 9","created_at":"2026-10-01T08:00:00Z","last_used_at":"2026-10-09T18:30:00Z","synced":true}, | |
| 51 | + | {"id":"BAUG","name":"yubikey","created_at":"2026-10-02T09:15:00Z","last_used_at":null,"synced":false} | |
| 52 | + | ]`, res.stdout) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestWebPasskeyListCleansServerText(t *testing.T) { | |
| 56 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |
| 57 | + | return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{{Id: "AQ", Name: "evil\x1b[2J name", CreatedAt: ts("2026-10-01T08:00:00Z")}}}, nil | |
| 58 | + | }} | |
| 59 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t) | |
| 60 | + | assert.NotContains(t, res.stdout, "\x1b") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestWebPasskeyRemove(t *testing.T) { | |
| 64 | + | f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) { | |
| 65 | + | return &ermite.RemovePasskeyResponse{}, nil | |
| 66 | + | }} | |
| 67 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID").ok(t) | |
| 68 | + | assert.Equal(t, "removed AQID\n", res.stdout) | |
| 69 | + | assert.Contains(t, res.stderr, "every browser is signed out") | |
| 70 | + | ||
| 71 | + | calls, reqs := f.snapshot() | |
| 72 | + | assert.Equal(t, []string{"RemovePasskey"}, calls) | |
| 73 | + | assert.Equal(t, "AQID", reqs[0].(*ermite.RemovePasskeyRequest).GetId()) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestWebPasskeyRemoveErrors(t *testing.T) { | |
| 77 | + | for name, tc := range map[string]struct { | |
| 78 | + | code connect.Code | |
| 79 | + | msg string | |
| 80 | + | want string | |
| 81 | + | }{ | |
| 82 | + | "unknown id": {connect.CodeNotFound, "not found", "not found"}, | |
| 83 | + | "not the owner": {connect.CodePermissionDenied, "", "permission denied"}, | |
| 84 | + | "unavailable": {connect.CodeFailedPrecondition, "passkeys are not available on this server", "passkeys are not available on this server"}, | |
| 85 | + | "bad id": {connect.CodeInvalidArgument, "id is not a base64url credential id", "invalid request"}, | |
| 86 | + | } { | |
| 87 | + | t.Run(name, func(t *testing.T) { | |
| 88 | + | f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) { | |
| 89 | + | return nil, connect.NewError(tc.code, errors.New(tc.msg)) | |
| 90 | + | }} | |
| 91 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID") | |
| 92 | + | require.Error(t, res.err) | |
| 93 | + | assert.Contains(t, res.err.Error(), tc.want) | |
| 94 | + | assert.Empty(t, res.stdout, "nothing is reported as removed") | |
| 95 | + | assert.NotContains(t, res.stderr, "signed out") | |
| 96 | + | }) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestWebPasskeyListUnavailable(t *testing.T) { | |
| 101 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |
| 102 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server")) | |
| 103 | + | }} | |
| 104 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list") | |
| 105 | + | require.Error(t, res.err) | |
| 106 | + | assert.Contains(t, res.err.Error(), "passkeys are not available on this server") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestWebPasskeyArgs(t *testing.T) { | |
| 110 | + | f := &fakeAPI{} | |
| 111 | + | url := f.serve(t) | |
| 112 | + | for _, args := range [][]string{ | |
| 113 | + | {"web", "passkey", "remove"}, | |
| 114 | + | {"web", "passkey", "remove", "a", "b"}, | |
| 115 | + | {"web", "passkey", "list", "extra"}, | |
| 116 | + | } { | |
| 117 | + | res := runCLI(t, envFor(url), "", args...) | |
| 118 | + | require.Error(t, res.err, args) | |
| 119 | + | } | |
| 120 | + | calls, _ := f.snapshot() | |
| 121 | + | assert.Empty(t, calls, "nothing reached the server") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // The ids complete from the server's list. | |
| 125 | + | func TestWebPasskeyRemoveCompletesIDs(t *testing.T) { | |
| 126 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |
| 127 | + | res := runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "").ok(t) | |
| 128 | + | assert.Contains(t, res.stdout, "AQID\tPixel 9") | |
| 129 | + | assert.Contains(t, res.stdout, "BAUG\tyubikey") | |
| 130 | + | ||
| 131 | + | res = runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "B").ok(t) | |
| 132 | + | assert.NotContains(t, res.stdout, "AQID") | |
| 133 | + | assert.Contains(t, res.stdout, "BAUG\tyubikey") | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func twoPasskeys() *ermite.ListPasskeysResponse { | |||
| 17 | + | return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{ | |||
| 18 | + | {Id: "AQID", Name: "Pixel 9", CreatedAt: ts("2026-10-01T08:00:00Z"), LastUsedAt: ts("2026-10-09T18:30:00Z"), Synced: true}, | |||
| 19 | + | {Id: "BAUG", Name: "yubikey", CreatedAt: ts("2026-10-02T09:15:00Z")}, | |||
| 20 | + | }} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestWebPasskeyList(t *testing.T) { | |||
| 24 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |||
| 25 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t) | |||
| 26 | + | assert.Empty(t, res.stderr) | |||
| 27 | + | assert.Regexp(t, `(?m)^ID\s+NAME\s+CREATED\s+LAST USED\s+SYNCED$`, res.stdout) | |||
| 28 | + | assert.Regexp(t, `(?m)^AQID\s+Pixel 9\s+2026-10-01 08:00\s+2026-10-09 18:30\s+yes$`, res.stdout) | |||
| 29 | + | assert.Regexp(t, `(?m)^BAUG\s+yubikey\s+2026-10-02 09:15\s+never\s+no$`, res.stdout) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestWebPasskeyListEmpty(t *testing.T) { | |||
| 33 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |||
| 34 | + | return &ermite.ListPasskeysResponse{}, nil | |||
| 35 | + | }} | |||
| 36 | + | url := f.serve(t) | |||
| 37 | + | res := runCLI(t, envFor(url), "", "web", "passkey", "list").ok(t) | |||
| 38 | + | assert.Empty(t, res.stdout) | |||
| 39 | + | assert.Contains(t, res.stderr, "no passkeys") | |||
| 40 | + | ||||
| 41 | + | res = runCLI(t, envFor(url), "", "web", "passkey", "list", "--json").ok(t) | |||
| 42 | + | assert.JSONEq(t, `[]`, res.stdout) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestWebPasskeyListJSON(t *testing.T) { | |||
| 46 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |||
| 47 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list", "--json").ok(t) | |||
| 48 | + | assert.Empty(t, res.stderr) | |||
| 49 | + | assert.JSONEq(t, `[ | |||
| 50 | + | {"id":"AQID","name":"Pixel 9","created_at":"2026-10-01T08:00:00Z","last_used_at":"2026-10-09T18:30:00Z","synced":true}, | |||
| 51 | + | {"id":"BAUG","name":"yubikey","created_at":"2026-10-02T09:15:00Z","last_used_at":null,"synced":false} | |||
| 52 | + | ]`, res.stdout) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestWebPasskeyListCleansServerText(t *testing.T) { | |||
| 56 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |||
| 57 | + | return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{{Id: "AQ", Name: "evil\x1b[2J name", CreatedAt: ts("2026-10-01T08:00:00Z")}}}, nil | |||
| 58 | + | }} | |||
| 59 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t) | |||
| 60 | + | assert.NotContains(t, res.stdout, "\x1b") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestWebPasskeyRemove(t *testing.T) { | |||
| 64 | + | f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) { | |||
| 65 | + | return &ermite.RemovePasskeyResponse{}, nil | |||
| 66 | + | }} | |||
| 67 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID").ok(t) | |||
| 68 | + | assert.Equal(t, "removed AQID\n", res.stdout) | |||
| 69 | + | assert.Contains(t, res.stderr, "every browser is signed out") | |||
| 70 | + | ||||
| 71 | + | calls, reqs := f.snapshot() | |||
| 72 | + | assert.Equal(t, []string{"RemovePasskey"}, calls) | |||
| 73 | + | assert.Equal(t, "AQID", reqs[0].(*ermite.RemovePasskeyRequest).GetId()) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestWebPasskeyRemoveErrors(t *testing.T) { | |||
| 77 | + | for name, tc := range map[string]struct { | |||
| 78 | + | code connect.Code | |||
| 79 | + | msg string | |||
| 80 | + | want string | |||
| 81 | + | }{ | |||
| 82 | + | "unknown id": {connect.CodeNotFound, "not found", "not found"}, | |||
| 83 | + | "not the owner": {connect.CodePermissionDenied, "", "permission denied"}, | |||
| 84 | + | "unavailable": {connect.CodeFailedPrecondition, "passkeys are not available on this server", "passkeys are not available on this server"}, | |||
| 85 | + | "bad id": {connect.CodeInvalidArgument, "id is not a base64url credential id", "invalid request"}, | |||
| 86 | + | } { | |||
| 87 | + | t.Run(name, func(t *testing.T) { | |||
| 88 | + | f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) { | |||
| 89 | + | return nil, connect.NewError(tc.code, errors.New(tc.msg)) | |||
| 90 | + | }} | |||
| 91 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID") | |||
| 92 | + | require.Error(t, res.err) | |||
| 93 | + | assert.Contains(t, res.err.Error(), tc.want) | |||
| 94 | + | assert.Empty(t, res.stdout, "nothing is reported as removed") | |||
| 95 | + | assert.NotContains(t, res.stderr, "signed out") | |||
| 96 | + | }) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestWebPasskeyListUnavailable(t *testing.T) { | |||
| 101 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { | |||
| 102 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server")) | |||
| 103 | + | }} | |||
| 104 | + | res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list") | |||
| 105 | + | require.Error(t, res.err) | |||
| 106 | + | assert.Contains(t, res.err.Error(), "passkeys are not available on this server") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestWebPasskeyArgs(t *testing.T) { | |||
| 110 | + | f := &fakeAPI{} | |||
| 111 | + | url := f.serve(t) | |||
| 112 | + | for _, args := range [][]string{ | |||
| 113 | + | {"web", "passkey", "remove"}, | |||
| 114 | + | {"web", "passkey", "remove", "a", "b"}, | |||
| 115 | + | {"web", "passkey", "list", "extra"}, | |||
| 116 | + | } { | |||
| 117 | + | res := runCLI(t, envFor(url), "", args...) | |||
| 118 | + | require.Error(t, res.err, args) | |||
| 119 | + | } | |||
| 120 | + | calls, _ := f.snapshot() | |||
| 121 | + | assert.Empty(t, calls, "nothing reached the server") | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // The ids complete from the server's list. | |||
| 125 | + | func TestWebPasskeyRemoveCompletesIDs(t *testing.T) { | |||
| 126 | + | f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }} | |||
| 127 | + | res := runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "").ok(t) | |||
| 128 | + | assert.Contains(t, res.stdout, "AQID\tPixel 9") | |||
| 129 | + | assert.Contains(t, res.stdout, "BAUG\tyubikey") | |||
| 130 | + | ||||
| 131 | + | res = runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "B").ok(t) | |||
| 132 | + | assert.NotContains(t, res.stdout, "AQID") | |||
| 133 | + | assert.Contains(t, res.stdout, "BAUG\tyubikey") | |||
| 134 | + | } | |||