1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "errors"
8 "fmt"
9
10 "connectrpc.com/connect"
11 "github.com/spf13/cobra"
12
13 "bnema.dev/ermite-api/gen/ermite"
14 "bnema.dev/ermite/internal/adapters/client"
15)
16
17// jsonLoginLink is the stable JSON shape of `ermite web login --json`.
18type jsonLoginLink struct {
19 URL string `json:"url"`
20 ExpiresAt *string `json:"expires_at"`
21}
22
23func newWebCmd(env *cliEnv) *cobra.Command {
24 web := &cobra.Command{
25 Use: "web",
26 Short: "Sign the owner in to the web interface of a remote server",
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 limited neither to repositories nor to branches.",
29 }
30 web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env))
31 return web
32}
33
34func newWebLoginCmd(env *cliEnv) *cobra.Command {
35 cmd := &cobra.Command{
36 Use: "login",
37 Short: "Print a one-time link that signs your browser in",
38 Long: "Ask the server for a login link and print it on standard output. Open it in your browser and confirm:\n" +
39 "the browser is then signed in as the owner for 12 hours (private repositories, merge requests).\n\n" +
40 "The link works once, for about a minute, and only until the server restarts. It is a secret: it is\n" +
41 "printed and nowhere else (not stored, not logged); do not paste it into a chat or a ticket. Opening\n" +
42 "it only shows a confirmation page; confirming it signs the browser in.\n\n" +
43 "JSON fields (--json): url, expires_at (RFC 3339 UTC).",
44 Args: cobra.NoArgs,
45 }
46 asJSON := addJSONFlag(cmd)
47 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
48 c, err := env.client(cmd)
49 if err != nil {
50 return err
51 }
52 res, err := c.Sessions().CreateLoginLink(env.ctx(cmd), connect.NewRequest(&ermite.CreateLoginLinkRequest{}))
53 if err != nil {
54 return c.Describe(err)
55 }
56 if *asJSON {
57 return writeJSON(cmd.OutOrStdout(), jsonLoginLink{URL: res.Msg.GetUrl(), ExpiresAt: jsonTime(res.Msg.GetExpiresAt())})
58 }
59 noticef(cmd, "open this link in your browser and confirm; it works once, until %s UTC", shortTime(res.Msg.GetExpiresAt(), "shortly"))
60 _, err = fmt.Fprintln(cmd.OutOrStdout(), res.Msg.GetUrl())
61 return err
62 }
63 return cmd
64}
65
66func newWebLogoutCmd(env *cliEnv) *cobra.Command {
67 var all bool
68 cmd := &cobra.Command{
69 Use: "logout --all",
70 Short: "Sign every browser out",
71 Long: "End every web session at once, on every device. Pending login links and API tokens are not affected\n" +
72 "(revoke tokens with `ermite token revoke`). To sign out one browser, use the sign out button on the page.",
73 Args: cobra.NoArgs,
74 }
75 cmd.Flags().BoolVar(&all, "all", false, "sign out every browser (required)")
76 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
77 if !all {
78 return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")
79 }
80 c, err := env.client(cmd)
81 if err != nil {
82 return err
83 }
84 if _, err := c.Sessions().SignOutEverywhere(env.ctx(cmd), connect.NewRequest(&ermite.SignOutEverywhereRequest{})); err != nil {
85 return c.Describe(err)
86 }
87 noticef(cmd, "every web session is signed out")
88 return nil
89 }
90 return cmd
91}
92
93// jsonPasskey is the stable JSON shape of a passkey. It never holds key
94// material.
95type 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
103const 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
106func 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
118func 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
126func 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)
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
173func 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)
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}