Files
jarl/cmd/jarl/token_rotate.go
T
abrown38andClaude Opus 5.5 0b2159b1a4 Add token rotation
jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.

Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:58:50 +00:00

269 lines
8.4 KiB
Go

package main
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"jarl/internal/api"
"jarl/internal/client"
)
// tokenRotateCmd is 'jarl token rotate'.
func tokenRotateCmd() *cobra.Command {
var user, grace, expires, write string
var current, noExpiry, save bool
cmd := &cobra.Command{
Use: "rotate [ID|NAME]",
Short: "Give a token a new secret, keeping its name, grants and ID",
Long: `Gives a token a new secret. The token keeps its ID, name, user and grants,
so nothing has to be set up again: only the programs that use it need the
new secret, which is printed once (or written with --write/--save).
The old secret stops working at once. With --grace it keeps working for a
while (at most 7d), so a client can be switched over without an outage;
jarld logs every use of the old secret, so you can see what still uses it.
To shut out a leaked token, rotate without --grace (or revoke it).
A token that expires gets its lifetime again, counted from now (a 90-day
token is good for another 90 days). --expires sets a new lifetime and
--no-expiry removes the expiry.
The token is given by ID, by name (your own tokens, or --user's), or with
--current, the token this command runs with. A restricted token may rotate
itself, which lets automation renew its own token.`,
Example: ` jarl token rotate 12
jarl token rotate laptop --save # and update the CLI's configuration
jarl token rotate --current --save --grace 10m
jarl token rotate deploy --user ci --grace 1h -q
jarl --token @/etc/jarl/certbot.token token rotate --current --write /etc/jarl/certbot.token`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if current == (len(args) == 1) {
return fmt.Errorf("give a token ID or name, or --current")
}
// Check that --save can work before the old secret is gone.
var origin tokenOrigin
if save {
var err error
if origin, err = findTokenOrigin(); err != nil {
return err
}
}
c, err := newClient()
if err != nil {
return err
}
var id int64
if current {
var who api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &who); err != nil {
return err
}
if who.Token == nil {
return fmt.Errorf("--current: this command isn't using a token")
}
id = who.Token.ID
} else if id, err = tokenID(c, args[0], user); err != nil {
return err
}
in := api.TokenRotation{Grace: grace, ExpiresIn: expires, NoExpiry: noExpiry}
var t api.Token
if err := c.Do(ctx(), "POST", fmt.Sprintf("/tokens/%d/rotate", id), nil, in, &t); err != nil {
return err
}
var saved []string
if write != "" {
if err := writeTokenFile(write, t.Secret); err != nil {
return fmt.Errorf("the token was rotated, but writing %s failed: %w\nnew token: %s", write, err, t.Secret)
}
saved = append(saved, write)
}
if save {
where, err := origin.store(t.Secret)
if err != nil {
return fmt.Errorf("the token was rotated, but saving it failed: %w\nnew token: %s", err, t.Secret)
}
saved = append(saved, where)
}
if flagQuiet {
if len(saved) == 0 {
fmt.Println(t.Secret)
}
return nil
}
return output(t, func(w io.Writer) {
if len(saved) == 0 {
fmt.Fprintf(w, "Token:\t%s\n", t.Secret)
}
fmt.Fprintf(w, "Name:\t%s (id %d, user %s)\n", t.Name, t.ID, t.Username)
if t.Grants != nil {
fmt.Fprintf(w, "Grants:\t%s\n", strings.Join(t.GrantStrings, "; "))
}
fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt))
if t.PrevExpiresAt != nil {
fmt.Fprintf(w, "Old secret:\tworks until %s\n", fmtTime(t.PrevExpiresAt))
} else {
fmt.Fprintf(w, "Old secret:\tno longer works\n")
}
if len(saved) > 0 {
fmt.Fprintf(w, "Saved to:\t%s\n", strings.Join(saved, ", "))
} else {
fmt.Fprintln(w, "\nThe new token is shown only once; store it now.")
if current && t.PrevExpiresAt == nil {
fmt.Fprintln(w, "This was the token this command ran with, so the CLI needs the new one (next time: --save).")
}
}
})
},
}
f := cmd.Flags()
f.BoolVar(&current, "current", false, "rotate the token this command runs with")
f.StringVar(&user, "user", "", "look the NAME up among this user's tokens (admin)")
f.StringVar(&grace, "grace", "", "keep the old secret working this long, e.g. 10m, 24h or 2d (at most 7d)")
f.StringVar(&expires, "expires", "", "new lifetime from now, e.g. 90d (default: the token's current lifetime again)")
f.BoolVar(&noExpiry, "no-expiry", false, "the token no longer expires")
f.StringVar(&write, "write", "", "write the new token to this file (mode 0600 if new) instead of printing it")
f.BoolVar(&save, "save", false, "store the new token where the CLI gets its token: its configuration file, or the @FILE it reads")
return cmd
}
// tokenID finds a token by ID or by name.
func tokenID(c *client.Client, arg, user string) (int64, error) {
if id, err := strconv.ParseInt(arg, 10, 64); err == nil {
return id, nil
}
path := "/me/tokens"
if user != "" {
uid, err := userID(c, user)
if err != nil {
return 0, err
}
path = fmt.Sprintf("/users/%d/tokens", uid)
}
var tokens []api.Token
if err := c.Get(ctx(), path, nil, &tokens); err != nil {
return 0, err
}
var ids []string
var found int64
for _, t := range tokens {
if t.Name == arg && t.RevokedAt == nil {
found = t.ID
ids = append(ids, strconv.FormatInt(t.ID, 10))
}
}
switch len(ids) {
case 0:
if user == "" {
return 0, fmt.Errorf("you have no token named %q (another user's: --user)", arg)
}
return 0, fmt.Errorf("user %s has no token named %q", user, arg)
case 1:
return found, nil
}
return 0, fmt.Errorf("several tokens are named %q (IDs %s); give the ID", arg, strings.Join(ids, ", "))
}
// tokenOrigin is where the CLI's token comes from, for --save.
type tokenOrigin struct {
file string // the token is read from this file (@FILE)
server string // else it's in the configuration file: this server's entry...
top bool // ...or the top-level token
}
// findTokenOrigin follows the same rules as newClient.
func findTokenOrigin() (tokenOrigin, error) {
cfg, err := loadCLIConfig()
if err != nil {
return tokenOrigin{}, err
}
ts, err := resolveTargets(cfg, targetSpec(cfg))
if err != nil {
return tokenOrigin{}, err
}
if len(ts) != 1 {
return tokenOrigin{}, fmt.Errorf("--save needs a single server")
}
t := ts[0]
var o tokenOrigin
var token string
switch {
case t.Name != "" && t.Token != "":
token, o.server = t.Token, t.Name
case flagToken != "":
token = flagToken
case os.Getenv("JARL_TOKEN") != "":
token = os.Getenv("JARL_TOKEN")
case t.Name != "":
token, o.server = t.Token, t.Name
default:
token, o.top = cfg.Token, true
}
if f, ok := strings.CutPrefix(token, "@"); ok {
return tokenOrigin{file: f}, nil
}
if !o.top && o.server == "" {
return tokenOrigin{}, fmt.Errorf("--save: the token comes from --token or JARL_TOKEN, so there's nowhere to save it; use --write FILE (or put the token in a file and pass --token @FILE)")
}
return o, nil
}
// store saves a new token where the old one came from, and says where.
func (o tokenOrigin) store(secret string) (string, error) {
if o.file != "" {
return o.file, writeTokenFile(o.file, secret)
}
cfg, err := loadCLIConfig()
if err != nil {
return "", err
}
if o.server != "" {
e := cfg.Servers[o.server]
if e == nil {
return "", fmt.Errorf("server %s is no longer in %s", o.server, flagConfig)
}
e.Token = secret
} else {
cfg.Token = secret
}
if err := saveCLIConfig(cfg); err != nil {
return "", err
}
if o.server != "" {
return flagConfig + " (server " + o.server + ")", nil
}
return flagConfig, nil
}
// saveTokenInConfig stores a token in the CLI configuration file, for the
// selected server (its named entry, or the top-level token).
func saveTokenInConfig(secret string) (string, error) {
cfg, err := loadCLIConfig()
if err != nil {
return "", err
}
ts, err := resolveTargets(cfg, targetSpec(cfg))
if err != nil {
return "", err
}
if len(ts) == 1 && ts[0].Name != "" {
if e := cfg.Servers[ts[0].Name]; e != nil {
e.Token = secret
return flagConfig + " (server " + ts[0].Name + ")", saveCLIConfig(cfg)
}
}
cfg.Token = secret
return flagConfig, saveCLIConfig(cfg)
}
// writeTokenFile replaces a token file's content. An existing file keeps
// its owner and mode; a new one is created with mode 0600.
func writeTokenFile(path, secret string) error {
return os.WriteFile(path, []byte(secret+"\n"), 0o600)
}